流出したclaude codeのソースコード4

@Kongyokongyo / 更新: 2026/04/19 12:45
MD 1.95MB
0

51: row: number, 52: ): void { 53: if (!s.isDragging) return 54: if (!s.focus && s.anchor && s.anchor.col === col && s.anchor.row === row) 55: return 56: s.focus = { col, row } 57: } 58: export function finishSelection(s: SelectionState): void { 59: s.isDragging = false 60: } 61: export function clearSelection(s: SelectionState): void { 62: s.anchor = null 63: s.focus = null 64: s.isDragging = false 65: s.anchorSpan = null 66: s.scrolledOffAbove = [] 67: s.scrolledOffBelow = [] 68: s.scrolledOffAboveSW = [] 69: s.scrolledOffBelowSW = [] 70: s.virtualAnchorRow = undefined 71: s.virtualFocusRow = undefined 72: s.lastPressHadAlt = false 73: } 74: const WORD_CHAR = /[\p{L}\p{N}/.-+~\]/u 75: function charClass(c: string): 0 | 1 | 2 { 76: if (c === ‘ ‘ || c === ‘’) return 0 77: if (WORD_CHAR.test(c)) return 1 78: return 2 79: } 80: /** 81: * Find the bounds of the same-class character run at (col, row). Returns 82: * null if the click is out of bounds or lands on a noSelect cell. Used by 83: * selectWordAt (initial double-click) and extendWordSelection (drag). 84: */ 85: function wordBoundsAt( 86: screen: Screen, 87: col: number, 88: row: number, 89: ): { lo: number; hi: number } | null { 90: if (row < 0 || row >= screen.height) return null 91: const width = screen.width 92: const noSelect = screen.noSelect 93: const rowOff = row * width 94: // If the click landed on the spacer tail of a wide char, step back to 95: // the head so the class check sees the actual grapheme. 96: let c = col 97: if (c > 0) { 98: const cell = cellAt(screen, c, row) 99: if (cell && cell.width === CellWidth.SpacerTail) c -= 1 100: } 101: if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return null 102: const startCell = cellAt(screen, c, row) 103: if (!startCell) return null 104: const cls = charClass(startCell.char) 105: // Expand left: include cells of the same class, stop at noSelect or 106: // class change. SpacerTail cells are stepped over (the wide-char head 107: // at the preceding column determines the class). 108: let lo = c 109: while (lo > 0) { 110: const prev = lo - 1 111: if (noSelect[rowOff + prev] === 1) break 112: const pc = cellAt(screen, prev, row) 113: if (!pc) break 114: if (pc.width === CellWidth.SpacerTail) { 115: // Step over the spacer to the wide-char head 116: if (prev === 0 || noSelect[rowOff + prev - 1] === 1) break 117: const head = cellAt(screen, prev - 1, row) 118: if (!head || charClass(head.char) !== cls) break 119: lo = prev - 1 120: continue 121: } 122: if (charClass(pc.char) !== cls) break 123: lo = prev 124: } 125: // Expand right: same logic, skipping spacer tails. 126: let hi = c 127: while (hi < width - 1) { 128: const next = hi + 1 129: if (noSelect[rowOff + next] === 1) break 130: const nc = cellAt(screen, next, row) 131: if (!nc) break 132: if (nc.width === CellWidth.SpacerTail) { 133: // Include the spacer tail in the selection range (it belongs to 134: // the wide char at hi) and continue past it. 135: hi = next 136: continue 137: } 138: if (charClass(nc.char) !== cls) break 139: hi = next 140: } 141: return { lo, hi } 142: } 143: /** -1 if a < b, 1 if a > b, 0 if equal (reading order: row then col). */ 144: function comparePoints(a: Point, b: Point): number { 145: if (a.row !== b.row) return a.row < b.row ? -1 : 1 146: if (a.col !== b.col) return a.col < b.col ? -1 : 1 147: return 0 148: } 149: /** 150: * Select the word at (col, row) by scanning the screen buffer for the 151: * bounds of the same-class character run. Mutates the selection in place. 152: * No-op if the click is out of bounds or lands on a noSelect cell. 153: * Sets isDragging=true and anchorSpan so a subsequent drag extends the 154: * selection word-by-word (native macOS behavior). 155: */ 156: export function selectWordAt( 157: s: SelectionState, 158: screen: Screen, 159: col: number, 160: row: number, 161: ): void { 162: const b = wordBoundsAt(screen, col, row) 163: if (!b) return 164: const lo = { col: b.lo, row } 165: const hi = { col: b.hi, row } 166: s.anchor = lo 167: s.focus = hi 168: s.isDragging = true 169: s.anchorSpan = { lo, hi, kind: ‘word’ } 170: } 171: const URL_BOUNDARY = new Set([…’<>”' ']) 172: function isUrlChar(c: string): boolean { 173: if (c.length !== 1) return false 174: const code = c.charCodeAt(0) 175: return code >= 0x21 && code <= 0x7e && !URL_BOUNDARY.has(c) 176: } 177: export function findPlainTextUrlAt( 178: screen: Screen, 179: col: number, 180: row: number, 181: ): string | undefined { 182: if (row < 0 || row >= screen.height) return undefined 183: const width = screen.width 184: const noSelect = screen.noSelect 185: const rowOff = row * width 186: let c = col 187: if (c > 0) { 188: const cell = cellAt(screen, c, row) 189: if (cell && cell.width === CellWidth.SpacerTail) c -= 1 190: } 191: if (c < 0 || c >= width || noSelect[rowOff + c] === 1) return undefined 192: const startCell = cellAt(screen, c, row) 193: if (!startCell || !isUrlChar(startCell.char)) return undefined 194: let lo = c 195: while (lo > 0) { 196: const prev = lo - 1 197: if (noSelect[rowOff + prev] === 1) break 198: const pc = cellAt(screen, prev, row) 199: if (!pc || pc.width !== CellWidth.Narrow || !isUrlChar(pc.char)) break 200: lo = prev 201: } 202: let hi = c 203: while (hi < width - 1) { 204: const next = hi + 1 205: if (noSelect[rowOff + next] === 1) break 206: const nc = cellAt(screen, next, row) 207: if (!nc || nc.width !== CellWidth.Narrow || !isUrlChar(nc.char)) break 208: hi = next 209: } 210: let token = '' 211: for (let i = lo; i <= hi; i++) token += cellAt(screen, i, row)!.char 212: // 1 cell = 1 char across [lo, hi] (ASCII-only run), so string index = 213: // column offset. Find the last scheme anchor at or before the click — 214: // a run like https://a.com,https://b.com has two, and clicking the 215: // second should return the second URL, not the greedy match of both. 216: const clickIdx = c - lo 217: const schemeRe = /(?:https?|file):\/\//g 218: let urlStart = -1 219: let urlEnd = token.length 220: for (let m; (m = schemeRe.exec(token)); ) { 221: if (m.index > clickIdx) { 222: urlEnd = m.index 223: break 224: } 225: urlStart = m.index 226: } 227: if (urlStart < 0) return undefined 228: let url = token.slice(urlStart, urlEnd) 229: // Strip trailing sentence punctuation. For closers () ] }, only strip 230: // if unbalanced — /wiki/Foo(bar) keeps ), /arr[0] keeps ]. 231: const OPENER: Record<string, string> = { ')': '(', ']': '[', '}': '{' } 232: while (url.length > 0) { 233: const last = url.at(-1)! 234: if ('.,;:!?'.includes(last)) { 235: url = url.slice(0, -1) 236: continue 237: } 238: const opener = OPENER[last] 239: if (!opener) break 240: let opens = 0 241: let closes = 0 242: for (let i = 0; i < url.length; i++) { 243: const ch = url.charAt(i) 244: if (ch === opener) opens++ 245: else if (ch === last) closes++ 246: } 247: if (closes > opens) url = url.slice(0, -1) 248: else break 249: } 250: // urlStart already guarantees click >= URL start; check right edge. 251: if (clickIdx >= urlStart + url.length) return undefined 252: return url 253: } 254: /** 255: * Select the entire row. Sets isDragging=true and anchorSpan so a 256: * subsequent drag extends the selection line-by-line. The anchor/focus 257: * span from col 0 to width-1; getSelectedText handles noSelect skipping 258: * and trailing-whitespace trimming so the copied text is just the visible 259: * line content. 260: */ 261: export function selectLineAt( 262: s: SelectionState, 263: screen: Screen, 264: row: number, 265: ): void { 266: if (row < 0 || row >= screen.height) return 267: const lo = { col: 0, row } 268: const hi = { col: screen.width - 1, row } 269: s.anchor = lo 270: s.focus = hi 271: s.isDragging = true 272: s.anchorSpan = { lo, hi, kind: 'line' } 273: } 274: export function extendSelection( 275: s: SelectionState, 276: screen: Screen, 277: col: number, 278: row: number, 279: ): void { 280: if (!s.isDragging || !s.anchorSpan) return 281: const span = s.anchorSpan 282: let mLo: Point 283: let mHi: Point 284: if (span.kind === 'word') { 285: const b = wordBoundsAt(screen, col, row) 286: mLo = { col: b ? b.lo : col, row } 287: mHi = { col: b ? b.hi : col, row } 288: } else { 289: const r = clamp(row, 0, screen.height - 1) 290: mLo = { col: 0, row: r } 291: mHi = { col: screen.width - 1, row: r } 292: } 293: if (comparePoints(mHi, span.lo) < 0) { 294: s.anchor = span.hi 295: s.focus = mLo 296: } else if (comparePoints(mLo, span.hi) > 0) { 297: s.anchor = span.lo 298: s.focus = mHi 299: } else { 300: s.anchor = span.lo 301: s.focus = span.hi 302: } 303: } 304: export type FocusMove = 305: | 'left' 306: | 'right' 307: | 'up' 308: | 'down' 309: | 'lineStart' 310: | 'lineEnd' 311: export function moveFocus(s: SelectionState, col: number, row: number): void { 312: if (!s.focus) return 313: s.anchorSpan = null 314: s.focus = { col, row } 315: s.virtualFocusRow = undefined 316: } 317: export function shiftSelection( 318: s: SelectionState, 319: dRow: number, 320: minRow: number, 321: maxRow: number, 322: width: number, 323: ): void { 324: if (!s.anchor || !s.focus) return 325: const vAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow 326: const vFocus = (s.virtualFocusRow ?? s.focus.row) + dRow 327: if ( 328: (vAnchor < minRow && vFocus < minRow) || 329: (vAnchor > maxRow && vFocus > maxRow) 330: ) { 331: clearSelection(s) 332: return 333: } 334: const oldMin = Math.min( 335: s.virtualAnchorRow ?? s.anchor.row, 336: s.virtualFocusRow ?? s.focus.row, 337: ) 338: const oldMax = Math.max( 339: s.virtualAnchorRow ?? s.anchor.row, 340: s.virtualFocusRow ?? s.focus.row, 341: ) 342: const oldAboveDebt = Math.max(0, minRow - oldMin) 343: const oldBelowDebt = Math.max(0, oldMax - maxRow) 344: const newAboveDebt = Math.max(0, minRow - Math.min(vAnchor, vFocus)) 345: const newBelowDebt = Math.max(0, Math.max(vAnchor, vFocus) - maxRow) 346: if (newAboveDebt < oldAboveDebt) { 347: const drop = oldAboveDebt - newAboveDebt 348: s.scrolledOffAbove.length -= drop 349: s.scrolledOffAboveSW.length = s.scrolledOffAbove.length 350: } 351: if (newBelowDebt < oldBelowDebt) { 352: const drop = oldBelowDebt - newBelowDebt 353: s.scrolledOffBelow.splice(0, drop) 354: s.scrolledOffBelowSW.splice(0, drop) 355: } 356: if (s.scrolledOffAbove.length > newAboveDebt) { 357: s.scrolledOffAbove = 358: newAboveDebt > 0 ? s.scrolledOffAbove.slice(-newAboveDebt) : [] 359: s.scrolledOffAboveSW = 360: newAboveDebt > 0 ? s.scrolledOffAboveSW.slice(-newAboveDebt) : [] 361: } 362: if (s.scrolledOffBelow.length > newBelowDebt) { 363: s.scrolledOffBelow = s.scrolledOffBelow.slice(0, newBelowDebt) 364: s.scrolledOffBelowSW = s.scrolledOffBelowSW.slice(0, newBelowDebt) 365: } 366: const shift = (p: Point, vRow: number): Point => { 367: if (vRow < minRow) return { col: 0, row: minRow } 368: if (vRow > maxRow) return { col: width - 1, row: maxRow } 369: return { col: p.col, row: vRow } 370: } 371: s.anchor = shift(s.anchor, vAnchor) 372: s.focus = shift(s.focus, vFocus) 373: s.virtualAnchorRow = 374: vAnchor < minRow || vAnchor > maxRow ? vAnchor : undefined 375: s.virtualFocusRow = vFocus < minRow || vFocus > maxRow ? vFocus : undefined 376: if (s.anchorSpan) { 377: const sp = (p: Point): Point => { 378: const r = p.row + dRow 379: if (r < minRow) return { col: 0, row: minRow } 380: if (r > maxRow) return { col: width - 1, row: maxRow } 381: return { col: p.col, row: r } 382: } 383: s.anchorSpan = { 384: lo: sp(s.anchorSpan.lo), 385: hi: sp(s.anchorSpan.hi), 386: kind: s.anchorSpan.kind, 387: } 388: } 389: } 390: export function shiftAnchor( 391: s: SelectionState, 392: dRow: number, 393: minRow: number, 394: maxRow: number, 395: ): void { 396: if (!s.anchor) return 397: const raw = (s.virtualAnchorRow ?? s.anchor.row) + dRow 398: s.anchor = { col: s.anchor.col, row: clamp(raw, minRow, maxRow) } 399: s.virtualAnchorRow = raw < minRow || raw > maxRow ? raw : undefined 400: if (s.anchorSpan) { 401: const shift = (p: Point): Point => ({ 402: col: p.col, 403: row: clamp(p.row + dRow, minRow, maxRow), 404: }) 405: s.anchorSpan = { 406: lo: shift(s.anchorSpan.lo), 407: hi: shift(s.anchorSpan.hi), 408: kind: s.anchorSpan.kind, 409: } 410: } 411: } 412: export function shiftSelectionForFollow( 413: s: SelectionState, 414: dRow: number, 415: minRow: number, 416: maxRow: number, 417: ): boolean { 418: if (!s.anchor) return false 419: const rawAnchor = (s.virtualAnchorRow ?? s.anchor.row) + dRow 420: const rawFocus = s.focus 421: ? (s.virtualFocusRow ?? s.focus.row) + dRow 422: : undefined 423: if (rawAnchor < minRow && rawFocus !== undefined && rawFocus < minRow) { 424: clearSelection(s) 425: return true 426: } 427: s.anchor = { col: s.anchor.col, row: clamp(rawAnchor, minRow, maxRow) } 428: if (s.focus && rawFocus !== undefined) { 429: s.focus = { col: s.focus.col, row: clamp(rawFocus, minRow, maxRow) } 430: } 431: s.virtualAnchorRow = 432: rawAnchor < minRow || rawAnchor > maxRow ? rawAnchor : undefined 433: s.virtualFocusRow = 434: rawFocus !== undefined && (rawFocus < minRow || rawFocus > maxRow) 435: ? rawFocus 436: : undefined 437: if (s.anchorSpan) { 438: const shift = (p: Point): Point => ({ 439: col: p.col, 440: row: clamp(p.row + dRow, minRow, maxRow), 441: }) 442: s.anchorSpan = { 443: lo: shift(s.anchorSpan.lo), 444: hi: shift(s.anchorSpan.hi), 445: kind: s.anchorSpan.kind, 446: } 447: } 448: return false 449: } 450: export function hasSelection(s: SelectionState): boolean { 451: return s.anchor !== null && s.focus !== null 452: } 453: export function selectionBounds(s: SelectionState): { 454: start: { col: number; row: number } 455: end: { col: number; row: number } 456: } | null { 457: if (!s.anchor || !s.focus) return null 458: return comparePoints(s.anchor, s.focus) <= 0 459: ? { start: s.anchor, end: s.focus } 460: : { start: s.focus, end: s.anchor } 461: } 462: export function isCellSelected( 463: s: SelectionState, 464: col: number, 465: row: number, 466: ): boolean { 467: const b = selectionBounds(s) 468: if (!b) return false 469: const { start, end } = b 470: if (row < start.row || row > end.row) return false 471: if (row === start.row && col < start.col) return false 472: if (row === end.row && col > end.col) return false 473: return true 474: } 475: function extractRowText( 476: screen: Screen, 477: row: number, 478: colStart: number, 479: colEnd: number, 480: ): string { 481: const noSelect = screen.noSelect 482: const rowOff = row * screen.width 483: const contentEnd = row + 1 < screen.height ? screen.softWrap[row + 1]! : 0 484: const lastCol = contentEnd > 0 ? Math.min(colEnd, contentEnd - 1) : colEnd 485: let line = '' 486: for (let col = colStart; col <= lastCol; col++) { 487: // Skip cells marked noSelect (gutters, line numbers, diff sigils). 488: // Check before cellAt to avoid the decode cost for excluded cells. 489: if (noSelect[rowOff + col] === 1) continue 490: const cell = cellAt(screen, col, row) 491: if (!cell) continue 492: // Skip spacer tails (second half of wide chars) — the head already 493: // contains the full grapheme. SpacerHead is a blank at line-end. 494: if ( 495: cell.width === CellWidth.SpacerTail || 496: cell.width === CellWidth.SpacerHead 497: ) { 498: continue 499: } 500: line += cell.char 501: } 502: return contentEnd > 0 ? line : line.replace(/\s+$/, '') 503: } 504: /** Accumulator for selected text that merges soft-wrapped rows back 505: * into logical lines. push(text, sw) appends a newline before text 506: * only when sw=false (i.e. the row starts a new logical line). Rows 507: * with sw=true are concatenated onto the previous row. */ 508: function joinRows( 509: lines: string[], 510: text: string, 511: sw: boolean | undefined, 512: ): void { 513: if (sw && lines.length > 0) { 514: lines[lines.length - 1] += text 515: } else { 516: lines.push(text) 517: } 518: } 519: /** 520: * Extract text from the screen buffer within the selection range. 521: * Rows are joined with newlines unless the screen's softWrap bitmap 522: * marks a row as a word-wrap continuation — those rows are concatenated 523: * onto the previous row so the copied text matches the logical source 524: * line, not the visual wrapped layout. Trailing whitespace on the last 525: * fragment of each logical line is trimmed. Wide-char spacer cells are 526: * skipped. Rows that scrolled out of the viewport during drag-to-scroll 527: * are joined back in from the scrolledOffAbove/Below accumulators along 528: * with their captured softWrap bits. 529: */ 530: export function getSelectedText(s: SelectionState, screen: Screen): string { 531: const b = selectionBounds(s) 532: if (!b) return '' 533: const { start, end } = b 534: const sw = screen.softWrap 535: const lines: string[] = [] 536: for (let i = 0; i < s.scrolledOffAbove.length; i++) { 537: joinRows(lines, s.scrolledOffAbove[i]!, s.scrolledOffAboveSW[i]) 538: } 539: for (let row = start.row; row <= end.row; row++) { 540: const rowStart = row === start.row ? start.col : 0 541: const rowEnd = row === end.row ? end.col : screen.width - 1 542: joinRows(lines, extractRowText(screen, row, rowStart, rowEnd), sw[row]! > 0) 543: } 544: for (let i = 0; i < s.scrolledOffBelow.length; i++) { 545: joinRows(lines, s.scrolledOffBelow[i]!, s.scrolledOffBelowSW[i]) 546: } 547: return lines.join('\n') 548: } 549: export function captureScrolledRows( 550: s: SelectionState, 551: screen: Screen, 552: firstRow: number, 553: lastRow: number, 554: side: 'above' | 'below', 555: ): void { 556: const b = selectionBounds(s) 557: if (!b || firstRow > lastRow) return 558: const { start, end } = b 559: const lo = Math.max(firstRow, start.row) 560: const hi = Math.min(lastRow, end.row) 561: if (lo > hi) return 562: const width = screen.width 563: const sw = screen.softWrap 564: const captured: string[] = [] 565: const capturedSW: boolean[] = [] 566: for (let row = lo; row <= hi; row++) { 567: const colStart = row === start.row ? start.col : 0 568: const colEnd = row === end.row ? end.col : width - 1 569: captured.push(extractRowText(screen, row, colStart, colEnd)) 570: capturedSW.push(sw[row]! > 0) 571: } 572: if (side === 'above') { 573: s.scrolledOffAbove.push(...captured) 574: s.scrolledOffAboveSW.push(...capturedSW) 575: if (s.anchor && s.anchor.row === start.row && lo === start.row) { 576: s.anchor = { col: 0, row: s.anchor.row } 577: if (s.anchorSpan) { 578: s.anchorSpan = { 579: kind: s.anchorSpan.kind, 580: lo: { col: 0, row: s.anchorSpan.lo.row }, 581: hi: { col: width - 1, row: s.anchorSpan.hi.row }, 582: } 583: } 584: } 585: } else { 586: s.scrolledOffBelow.unshift(...captured) 587: s.scrolledOffBelowSW.unshift(...capturedSW) 588: if (s.anchor && s.anchor.row === end.row && hi === end.row) { 589: s.anchor = { col: width - 1, row: s.anchor.row } 590: if (s.anchorSpan) { 591: s.anchorSpan = { 592: kind: s.anchorSpan.kind, 593: lo: { col: 0, row: s.anchorSpan.lo.row }, 594: hi: { col: width - 1, row: s.anchorSpan.hi.row }, 595: } 596: } 597: } 598: } 599: } 600: export function applySelectionOverlay( 601: screen: Screen, 602: selection: SelectionState, 603: stylePool: StylePool, 604: ): void { 605: const b = selectionBounds(selection) 606: if (!b) return 607: const { start, end } = b 608: const width = screen.width 609: const noSelect = screen.noSelect 610: for (let row = start.row; row <= end.row && row < screen.height; row++) { 611: const colStart = row === start.row ? start.col : 0 612: const colEnd = row === end.row ? Math.min(end.col, width - 1) : width - 1 613: const rowOff = row * width 614: for (let col = colStart; col <= colEnd; col++) { 615: const idx = rowOff + col 616: if (noSelect[idx] === 1) continue 617: const cell = cellAtIndex(screen, idx) 618: setCellStyleId(screen, col, row, stylePool.withSelectionBg(cell.styleId)) 619: } 620: } 621: } ```

File: src/ink/squash-text-nodes.ts

typescript 1: import type { DOMElement } from './dom.js' 2: import type { TextStyles } from './styles.js' 3: export type StyledSegment = { 4: text: string 5: styles: TextStyles 6: hyperlink?: string 7: } 8: export function squashTextNodesToSegments( 9: node: DOMElement, 10: inheritedStyles: TextStyles = {}, 11: inheritedHyperlink?: string, 12: out: StyledSegment[] = [], 13: ): StyledSegment[] { 14: const mergedStyles = node.textStyles 15: ? { ...inheritedStyles, ...node.textStyles } 16: : inheritedStyles 17: for (const childNode of node.childNodes) { 18: if (childNode === undefined) { 19: continue 20: } 21: if (childNode.nodeName === '#text') { 22: if (childNode.nodeValue.length > 0) { 23: out.push({ 24: text: childNode.nodeValue, 25: styles: mergedStyles, 26: hyperlink: inheritedHyperlink, 27: }) 28: } 29: } else if ( 30: childNode.nodeName === 'ink-text' || 31: childNode.nodeName === 'ink-virtual-text' 32: ) { 33: squashTextNodesToSegments( 34: childNode, 35: mergedStyles, 36: inheritedHyperlink, 37: out, 38: ) 39: } else if (childNode.nodeName === 'ink-link') { 40: const href = childNode.attributes['href'] as string | undefined 41: squashTextNodesToSegments( 42: childNode, 43: mergedStyles, 44: href || inheritedHyperlink, 45: out, 46: ) 47: } 48: } 49: return out 50: } 51: function squashTextNodes(node: DOMElement): string { 52: let text = '' 53: for (const childNode of node.childNodes) { 54: if (childNode === undefined) { 55: continue 56: } 57: if (childNode.nodeName === '#text') { 58: text += childNode.nodeValue 59: } else if ( 60: childNode.nodeName === 'ink-text' || 61: childNode.nodeName === 'ink-virtual-text' 62: ) { 63: text += squashTextNodes(childNode) 64: } else if (childNode.nodeName === 'ink-link') { 65: text += squashTextNodes(childNode) 66: } 67: } 68: return text 69: } 70: export default squashTextNodes

File: src/ink/stringWidth.ts

typescript 1: import emojiRegex from 'emoji-regex' 2: import { eastAsianWidth } from 'get-east-asian-width' 3: import stripAnsi from 'strip-ansi' 4: import { getGraphemeSegmenter } from '../utils/intl.js' 5: const EMOJI_REGEX = emojiRegex() 6: function stringWidthJavaScript(str: string): number { 7: if (typeof str !== 'string' || str.length === 0) { 8: return 0 9: } 10: let isPureAscii = true 11: for (let i = 0; i < str.length; i++) { 12: const code = str.charCodeAt(i) 13: if (code >= 127 || code === 0x1b) { 14: isPureAscii = false 15: break 16: } 17: } 18: if (isPureAscii) { 19: let width = 0 20: for (let i = 0; i < str.length; i++) { 21: const code = str.charCodeAt(i) 22: if (code > 0x1f) { 23: width++ 24: } 25: } 26: return width 27: } 28: if (str.includes('\x1b')) { 29: str = stripAnsi(str) 30: if (str.length === 0) { 31: return 0 32: } 33: } 34: if (!needsSegmentation(str)) { 35: let width = 0 36: for (const char of str) { 37: const codePoint = char.codePointAt(0)! 38: if (!isZeroWidth(codePoint)) { 39: width += eastAsianWidth(codePoint, { ambiguousAsWide: false }) 40: } 41: } 42: return width 43: } 44: let width = 0 45: for (const { segment: grapheme } of getGraphemeSegmenter().segment(str)) { 46: EMOJI_REGEX.lastIndex = 0 47: if (EMOJI_REGEX.test(grapheme)) { 48: width += getEmojiWidth(grapheme) 49: continue 50: } 51: for (const char of grapheme) { 52: const codePoint = char.codePointAt(0)! 53: if (!isZeroWidth(codePoint)) { 54: width += eastAsianWidth(codePoint, { ambiguousAsWide: false }) 55: break 56: } 57: } 58: } 59: return width 60: } 61: function needsSegmentation(str: string): boolean { 62: for (const char of str) { 63: const cp = char.codePointAt(0)! 64: if (cp >= 0x1f300 && cp <= 0x1faff) return true 65: if (cp >= 0x2600 && cp <= 0x27bf) return true 66: if (cp >= 0x1f1e6 && cp <= 0x1f1ff) return true 67: if (cp >= 0xfe00 && cp <= 0xfe0f) return true 68: if (cp === 0x200d) return true 69: } 70: return false 71: } 72: function getEmojiWidth(grapheme: string): number { 73: const first = grapheme.codePointAt(0)! 74: if (first >= 0x1f1e6 && first <= 0x1f1ff) { 75: let count = 0 76: for (const _ of grapheme) count++ 77: return count === 1 ? 1 : 2 78: } 79: if (grapheme.length === 2) { 80: const second = grapheme.codePointAt(1) 81: if ( 82: second === 0xfe0f && 83: ((first >= 0x30 && first <= 0x39) || first === 0x23 || first === 0x2a) 84: ) { 85: return 1 86: } 87: } 88: return 2 89: } 90: function isZeroWidth(codePoint: number): boolean { 91: if (codePoint >= 0x20 && codePoint < 0x7f) return false 92: if (codePoint >= 0xa0 && codePoint < 0x0300) return codePoint === 0x00ad 93: if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) return true 94: if ( 95: (codePoint >= 0x200b && codePoint <= 0x200d) || 96: codePoint === 0xfeff || 97: (codePoint >= 0x2060 && codePoint <= 0x2064) 98: ) { 99: return true 100: } 101: if ( 102: (codePoint >= 0xfe00 && codePoint <= 0xfe0f) || 103: (codePoint >= 0xe0100 && codePoint <= 0xe01ef) 104: ) { 105: return true 106: } 107: if ( 108: (codePoint >= 0x0300 && codePoint <= 0x036f) || 109: (codePoint >= 0x1ab0 && codePoint <= 0x1aff) || 110: (codePoint >= 0x1dc0 && codePoint <= 0x1dff) || 111: (codePoint >= 0x20d0 && codePoint <= 0x20ff) || 112: (codePoint >= 0xfe20 && codePoint <= 0xfe2f) 113: ) { 114: return true 115: } 116: if (codePoint >= 0x0900 && codePoint <= 0x0d4f) { 117: const offset = codePoint & 0x7f 118: if (offset <= 0x03) return true 119: if (offset >= 0x3a && offset <= 0x4f) return true 120: if (offset >= 0x51 && offset <= 0x57) return true 121: if (offset >= 0x62 && offset <= 0x63) return true 122: } 123: if ( 124: codePoint === 0x0e31 || 125: (codePoint >= 0x0e34 && codePoint <= 0x0e3a) || 126: (codePoint >= 0x0e47 && codePoint <= 0x0e4e) || 127: codePoint === 0x0eb1 || 128: (codePoint >= 0x0eb4 && codePoint <= 0x0ebc) || 129: (codePoint >= 0x0ec8 && codePoint <= 0x0ecd) 130: ) { 131: return true 132: } 133: if ( 134: (codePoint >= 0x0600 && codePoint <= 0x0605) || 135: codePoint === 0x06dd || 136: codePoint === 0x070f || 137: codePoint === 0x08e2 138: ) { 139: return true 140: } 141: if (codePoint >= 0xd800 && codePoint <= 0xdfff) return true 142: if (codePoint >= 0xe0000 && codePoint <= 0xe007f) return true 143: return false 144: } 145: const bunStringWidth = 146: typeof Bun !== 'undefined' && typeof Bun.stringWidth === 'function' 147: ? Bun.stringWidth 148: : null 149: const BUN_STRING_WIDTH_OPTS = { ambiguousIsNarrow: true } as const 150: export const stringWidth: (str: string) => number = bunStringWidth 151: ? str => bunStringWidth(str, BUN_STRING_WIDTH_OPTS) 152: : stringWidthJavaScript

File: src/ink/styles.ts

typescript 1: import { 2: LayoutAlign, 3: LayoutDisplay, 4: LayoutEdge, 5: LayoutFlexDirection, 6: LayoutGutter, 7: LayoutJustify, 8: type LayoutNode, 9: LayoutOverflow, 10: LayoutPositionType, 11: LayoutWrap, 12: } from './layout/node.js' 13: import type { BorderStyle, BorderTextOptions } from './render-border.js' 14: export type RGBColor = `rgb(${number},${number},${number})` 15: export type HexColor = `#${string}` 16: export type Ansi256Color = `ansi256(${number})` 17: export type AnsiColor = 18: | 'ansi:black' 19: | 'ansi:red' 20: | 'ansi:green' 21: | 'ansi:yellow' 22: | 'ansi:blue' 23: | 'ansi:magenta' 24: | 'ansi:cyan' 25: | 'ansi:white' 26: | 'ansi:blackBright' 27: | 'ansi:redBright' 28: | 'ansi:greenBright' 29: | 'ansi:yellowBright' 30: | 'ansi:blueBright' 31: | 'ansi:magentaBright' 32: | 'ansi:cyanBright' 33: | 'ansi:whiteBright' 34: export type Color = RGBColor | HexColor | Ansi256Color | AnsiColor 35: export type TextStyles = { 36: readonly color?: Color 37: readonly backgroundColor?: Color 38: readonly dim?: boolean 39: readonly bold?: boolean 40: readonly italic?: boolean 41: readonly underline?: boolean 42: readonly strikethrough?: boolean 43: readonly inverse?: boolean 44: } 45: export type Styles = { 46: readonly textWrap?: 47: | 'wrap' 48: | 'wrap-trim' 49: | 'end' 50: | 'middle' 51: | 'truncate-end' 52: | 'truncate' 53: | 'truncate-middle' 54: | 'truncate-start' 55: readonly position?: 'absolute' | 'relative' 56: readonly top?: number | `${number}%` 57: readonly bottom?: number | `${number}%` 58: readonly left?: number | `${number}%` 59: readonly right?: number | `${number}%` 60: readonly columnGap?: number 61: readonly rowGap?: number 62: readonly gap?: number 63: readonly margin?: number 64: readonly marginX?: number 65: readonly marginY?: number 66: readonly marginTop?: number 67: readonly marginBottom?: number 68: readonly marginLeft?: number 69: readonly marginRight?: number 70: readonly padding?: number 71: readonly paddingX?: number 72: readonly paddingY?: number 73: readonly paddingTop?: number 74: readonly paddingBottom?: number 75: readonly paddingLeft?: number 76: readonly paddingRight?: number 77: readonly flexGrow?: number 78: readonly flexShrink?: number 79: readonly flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse' 80: readonly flexBasis?: number | string 81: readonly flexWrap?: 'nowrap' | 'wrap' | 'wrap-reverse' 82: readonly alignItems?: 'flex-start' | 'center' | 'flex-end' | 'stretch' 83: readonly alignSelf?: 'flex-start' | 'center' | 'flex-end' | 'auto' 84: readonly justifyContent?: 85: | 'flex-start' 86: | 'flex-end' 87: | 'space-between' 88: | 'space-around' 89: | 'space-evenly' 90: | 'center' 91: readonly width?: number | string 92: readonly height?: number | string 93: readonly minWidth?: number | string 94: readonly minHeight?: number | string 95: readonly maxWidth?: number | string 96: readonly maxHeight?: number | string 97: readonly display?: 'flex' | 'none' 98: readonly borderStyle?: BorderStyle 99: readonly borderTop?: boolean 100: readonly borderBottom?: boolean 101: readonly borderLeft?: boolean 102: readonly borderRight?: boolean 103: readonly borderColor?: Color 104: readonly borderTopColor?: Color 105: readonly borderBottomColor?: Color 106: readonly borderLeftColor?: Color 107: readonly borderRightColor?: Color 108: readonly borderDimColor?: boolean 109: readonly borderTopDimColor?: boolean 110: readonly borderBottomDimColor?: boolean 111: readonly borderLeftDimColor?: boolean 112: readonly borderRightDimColor?: boolean 113: readonly borderText?: BorderTextOptions 114: readonly backgroundColor?: Color 115: readonly opaque?: boolean 116: readonly overflow?: 'visible' | 'hidden' | 'scroll' 117: readonly overflowX?: 'visible' | 'hidden' | 'scroll' 118: readonly overflowY?: 'visible' | 'hidden' | 'scroll' 119: readonly noSelect?: boolean | 'from-left-edge' 120: } 121: const applyPositionStyles = (node: LayoutNode, style: Styles): void => { 122: if ('position' in style) { 123: node.setPositionType( 124: style.position === 'absolute' 125: ? LayoutPositionType.Absolute 126: : LayoutPositionType.Relative, 127: ) 128: } 129: if ('top' in style) applyPositionEdge(node, 'top', style.top) 130: if ('bottom' in style) applyPositionEdge(node, 'bottom', style.bottom) 131: if ('left' in style) applyPositionEdge(node, 'left', style.left) 132: if ('right' in style) applyPositionEdge(node, 'right', style.right) 133: } 134: function applyPositionEdge( 135: node: LayoutNode, 136: edge: 'top' | 'bottom' | 'left' | 'right', 137: v: number | `${number}%` | undefined, 138: ): void { 139: if (typeof v === 'string') { 140: node.setPositionPercent(edge, Number.parseInt(v, 10)) 141: } else if (typeof v === 'number') { 142: node.setPosition(edge, v) 143: } else { 144: node.setPosition(edge, Number.NaN) 145: } 146: } 147: const applyOverflowStyles = (node: LayoutNode, style: Styles): void => { 148: const y = style.overflowY ?? style.overflow 149: const x = style.overflowX ?? style.overflow 150: if (y === 'scroll' || x === 'scroll') { 151: node.setOverflow(LayoutOverflow.Scroll) 152: } else if (y === 'hidden' || x === 'hidden') { 153: node.setOverflow(LayoutOverflow.Hidden) 154: } else if ( 155: 'overflow' in style || 156: 'overflowX' in style || 157: 'overflowY' in style 158: ) { 159: node.setOverflow(LayoutOverflow.Visible) 160: } 161: } 162: const applyMarginStyles = (node: LayoutNode, style: Styles): void => { 163: if ('margin' in style) { 164: node.setMargin(LayoutEdge.All, style.margin ?? 0) 165: } 166: if ('marginX' in style) { 167: node.setMargin(LayoutEdge.Horizontal, style.marginX ?? 0) 168: } 169: if ('marginY' in style) { 170: node.setMargin(LayoutEdge.Vertical, style.marginY ?? 0) 171: } 172: if ('marginLeft' in style) { 173: node.setMargin(LayoutEdge.Start, style.marginLeft || 0) 174: } 175: if ('marginRight' in style) { 176: node.setMargin(LayoutEdge.End, style.marginRight || 0) 177: } 178: if ('marginTop' in style) { 179: node.setMargin(LayoutEdge.Top, style.marginTop || 0) 180: } 181: if ('marginBottom' in style) { 182: node.setMargin(LayoutEdge.Bottom, style.marginBottom || 0) 183: } 184: } 185: const applyPaddingStyles = (node: LayoutNode, style: Styles): void => { 186: if ('padding' in style) { 187: node.setPadding(LayoutEdge.All, style.padding ?? 0) 188: } 189: if ('paddingX' in style) { 190: node.setPadding(LayoutEdge.Horizontal, style.paddingX ?? 0) 191: } 192: if ('paddingY' in style) { 193: node.setPadding(LayoutEdge.Vertical, style.paddingY ?? 0) 194: } 195: if ('paddingLeft' in style) { 196: node.setPadding(LayoutEdge.Left, style.paddingLeft || 0) 197: } 198: if ('paddingRight' in style) { 199: node.setPadding(LayoutEdge.Right, style.paddingRight || 0) 200: } 201: if ('paddingTop' in style) { 202: node.setPadding(LayoutEdge.Top, style.paddingTop || 0) 203: } 204: if ('paddingBottom' in style) { 205: node.setPadding(LayoutEdge.Bottom, style.paddingBottom || 0) 206: } 207: } 208: const applyFlexStyles = (node: LayoutNode, style: Styles): void => { 209: if ('flexGrow' in style) { 210: node.setFlexGrow(style.flexGrow ?? 0) 211: } 212: if ('flexShrink' in style) { 213: node.setFlexShrink( 214: typeof style.flexShrink === 'number' ? style.flexShrink : 1, 215: ) 216: } 217: if ('flexWrap' in style) { 218: if (style.flexWrap === 'nowrap') { 219: node.setFlexWrap(LayoutWrap.NoWrap) 220: } 221: if (style.flexWrap === 'wrap') { 222: node.setFlexWrap(LayoutWrap.Wrap) 223: } 224: if (style.flexWrap === 'wrap-reverse') { 225: node.setFlexWrap(LayoutWrap.WrapReverse) 226: } 227: } 228: if ('flexDirection' in style) { 229: if (style.flexDirection === 'row') { 230: node.setFlexDirection(LayoutFlexDirection.Row) 231: } 232: if (style.flexDirection === 'row-reverse') { 233: node.setFlexDirection(LayoutFlexDirection.RowReverse) 234: } 235: if (style.flexDirection === 'column') { 236: node.setFlexDirection(LayoutFlexDirection.Column) 237: } 238: if (style.flexDirection === 'column-reverse') { 239: node.setFlexDirection(LayoutFlexDirection.ColumnReverse) 240: } 241: } 242: if ('flexBasis' in style) { 243: if (typeof style.flexBasis === 'number') { 244: node.setFlexBasis(style.flexBasis) 245: } else if (typeof style.flexBasis === 'string') { 246: node.setFlexBasisPercent(Number.parseInt(style.flexBasis, 10)) 247: } else { 248: node.setFlexBasis(Number.NaN) 249: } 250: } 251: if ('alignItems' in style) { 252: if (style.alignItems === 'stretch' || !style.alignItems) { 253: node.setAlignItems(LayoutAlign.Stretch) 254: } 255: if (style.alignItems === 'flex-start') { 256: node.setAlignItems(LayoutAlign.FlexStart) 257: } 258: if (style.alignItems === 'center') { 259: node.setAlignItems(LayoutAlign.Center) 260: } 261: if (style.alignItems === 'flex-end') { 262: node.setAlignItems(LayoutAlign.FlexEnd) 263: } 264: } 265: if ('alignSelf' in style) { 266: if (style.alignSelf === 'auto' || !style.alignSelf) { 267: node.setAlignSelf(LayoutAlign.Auto) 268: } 269: if (style.alignSelf === 'flex-start') { 270: node.setAlignSelf(LayoutAlign.FlexStart) 271: } 272: if (style.alignSelf === 'center') { 273: node.setAlignSelf(LayoutAlign.Center) 274: } 275: if (style.alignSelf === 'flex-end') { 276: node.setAlignSelf(LayoutAlign.FlexEnd) 277: } 278: } 279: if ('justifyContent' in style) { 280: if (style.justifyContent === 'flex-start' || !style.justifyContent) { 281: node.setJustifyContent(LayoutJustify.FlexStart) 282: } 283: if (style.justifyContent === 'center') { 284: node.setJustifyContent(LayoutJustify.Center) 285: } 286: if (style.justifyContent === 'flex-end') { 287: node.setJustifyContent(LayoutJustify.FlexEnd) 288: } 289: if (style.justifyContent === 'space-between') { 290: node.setJustifyContent(LayoutJustify.SpaceBetween) 291: } 292: if (style.justifyContent === 'space-around') { 293: node.setJustifyContent(LayoutJustify.SpaceAround) 294: } 295: if (style.justifyContent === 'space-evenly') { 296: node.setJustifyContent(LayoutJustify.SpaceEvenly) 297: } 298: } 299: } 300: const applyDimensionStyles = (node: LayoutNode, style: Styles): void => { 301: if ('width' in style) { 302: if (typeof style.width === 'number') { 303: node.setWidth(style.width) 304: } else if (typeof style.width === 'string') { 305: node.setWidthPercent(Number.parseInt(style.width, 10)) 306: } else { 307: node.setWidthAuto() 308: } 309: } 310: if ('height' in style) { 311: if (typeof style.height === 'number') { 312: node.setHeight(style.height) 313: } else if (typeof style.height === 'string') { 314: node.setHeightPercent(Number.parseInt(style.height, 10)) 315: } else { 316: node.setHeightAuto() 317: } 318: } 319: if ('minWidth' in style) { 320: if (typeof style.minWidth === 'string') { 321: node.setMinWidthPercent(Number.parseInt(style.minWidth, 10)) 322: } else { 323: node.setMinWidth(style.minWidth ?? 0) 324: } 325: } 326: if ('minHeight' in style) { 327: if (typeof style.minHeight === 'string') { 328: node.setMinHeightPercent(Number.parseInt(style.minHeight, 10)) 329: } else { 330: node.setMinHeight(style.minHeight ?? 0) 331: } 332: } 333: if ('maxWidth' in style) { 334: if (typeof style.maxWidth === 'string') { 335: node.setMaxWidthPercent(Number.parseInt(style.maxWidth, 10)) 336: } else { 337: node.setMaxWidth(style.maxWidth ?? 0) 338: } 339: } 340: if ('maxHeight' in style) { 341: if (typeof style.maxHeight === 'string') { 342: node.setMaxHeightPercent(Number.parseInt(style.maxHeight, 10)) 343: } else { 344: node.setMaxHeight(style.maxHeight ?? 0) 345: } 346: } 347: } 348: const applyDisplayStyles = (node: LayoutNode, style: Styles): void => { 349: if ('display' in style) { 350: node.setDisplay( 351: style.display === 'flex' ? LayoutDisplay.Flex : LayoutDisplay.None, 352: ) 353: } 354: } 355: const applyBorderStyles = ( 356: node: LayoutNode, 357: style: Styles, 358: resolvedStyle?: Styles, 359: ): void => { 360: const resolved = resolvedStyle ?? style 361: if ('borderStyle' in style) { 362: const borderWidth = style.borderStyle ? 1 : 0 363: node.setBorder( 364: LayoutEdge.Top, 365: resolved.borderTop !== false ? borderWidth : 0, 366: ) 367: node.setBorder( 368: LayoutEdge.Bottom, 369: resolved.borderBottom !== false ? borderWidth : 0, 370: ) 371: node.setBorder( 372: LayoutEdge.Left, 373: resolved.borderLeft !== false ? borderWidth : 0, 374: ) 375: node.setBorder( 376: LayoutEdge.Right, 377: resolved.borderRight !== false ? borderWidth : 0, 378: ) 379: } else { 380: if ('borderTop' in style && style.borderTop !== undefined) { 381: node.setBorder(LayoutEdge.Top, style.borderTop === false ? 0 : 1) 382: } 383: if ('borderBottom' in style && style.borderBottom !== undefined) { 384: node.setBorder(LayoutEdge.Bottom, style.borderBottom === false ? 0 : 1) 385: } 386: if ('borderLeft' in style && style.borderLeft !== undefined) { 387: node.setBorder(LayoutEdge.Left, style.borderLeft === false ? 0 : 1) 388: } 389: if ('borderRight' in style && style.borderRight !== undefined) { 390: node.setBorder(LayoutEdge.Right, style.borderRight === false ? 0 : 1) 391: } 392: } 393: } 394: const applyGapStyles = (node: LayoutNode, style: Styles): void => { 395: if ('gap' in style) { 396: node.setGap(LayoutGutter.All, style.gap ?? 0) 397: } 398: if ('columnGap' in style) { 399: node.setGap(LayoutGutter.Column, style.columnGap ?? 0) 400: } 401: if ('rowGap' in style) { 402: node.setGap(LayoutGutter.Row, style.rowGap ?? 0) 403: } 404: } 405: const styles = ( 406: node: LayoutNode, 407: style: Styles = {}, 408: resolvedStyle?: Styles, 409: ): void => { 410: applyPositionStyles(node, style) 411: applyOverflowStyles(node, style) 412: applyMarginStyles(node, style) 413: applyPaddingStyles(node, style) 414: applyFlexStyles(node, style) 415: applyDimensionStyles(node, style) 416: applyDisplayStyles(node, style) 417: applyBorderStyles(node, style, resolvedStyle) 418: applyGapStyles(node, style) 419: } 420: export default styles

File: src/ink/supports-hyperlinks.ts

typescript 1: import supportsHyperlinksLib from 'supports-hyperlinks' 2: export const ADDITIONAL_HYPERLINK_TERMINALS = [ 3: 'ghostty', 4: 'Hyper', 5: 'kitty', 6: 'alacritty', 7: 'iTerm.app', 8: 'iTerm2', 9: ] 10: type EnvLike = Record<string, string | undefined> 11: type SupportsHyperlinksOptions = { 12: env?: EnvLike 13: stdoutSupported?: boolean 14: } 15: export function supportsHyperlinks( 16: options?: SupportsHyperlinksOptions, 17: ): boolean { 18: const stdoutSupported = 19: options?.stdoutSupported ?? supportsHyperlinksLib.stdout 20: if (stdoutSupported) { 21: return true 22: } 23: const env = options?.env ?? process.env 24: const termProgram = env['TERM_PROGRAM'] 25: if (termProgram && ADDITIONAL_HYPERLINK_TERMINALS.includes(termProgram)) { 26: return true 27: } 28: const lcTerminal = env['LC_TERMINAL'] 29: if (lcTerminal && ADDITIONAL_HYPERLINK_TERMINALS.includes(lcTerminal)) { 30: return true 31: } 32: const term = env['TERM'] 33: if (term?.includes('kitty')) { 34: return true 35: } 36: return false 37: }

File: src/ink/tabstops.ts

typescript 1: import { stringWidth } from './stringWidth.js' 2: import { createTokenizer } from './termio/tokenize.js' 3: const DEFAULT_TAB_INTERVAL = 8 4: export function expandTabs( 5: text: string, 6: interval = DEFAULT_TAB_INTERVAL, 7: ): string { 8: if (!text.includes('\t')) { 9: return text 10: } 11: const tokenizer = createTokenizer() 12: const tokens = tokenizer.feed(text) 13: tokens.push(...tokenizer.flush()) 14: let result = '' 15: let column = 0 16: for (const token of tokens) { 17: if (token.type === 'sequence') { 18: result += token.value 19: } else { 20: const parts = token.value.split(/(\t|\n)/) 21: for (const part of parts) { 22: if (part === '\t') { 23: const spaces = interval - (column % interval) 24: result += ' '.repeat(spaces) 25: column += spaces 26: } else if (part === '\n') { 27: result += part 28: column = 0 29: } else { 30: result += part 31: column += stringWidth(part) 32: } 33: } 34: } 35: } 36: return result 37: }

File: src/ink/terminal-focus-state.ts

typescript 1: export type TerminalFocusState = 'focused' | 'blurred' | 'unknown' 2: let focusState: TerminalFocusState = 'unknown' 3: const resolvers: Set<() => void> = new Set() 4: const subscribers: Set<() => void> = new Set() 5: export function setTerminalFocused(v: boolean): void { 6: focusState = v ? 'focused' : 'blurred' 7: for (const cb of subscribers) { 8: cb() 9: } 10: if (!v) { 11: for (const resolve of resolvers) { 12: resolve() 13: } 14: resolvers.clear() 15: } 16: } 17: export function getTerminalFocused(): boolean { 18: return focusState !== 'blurred' 19: } 20: export function getTerminalFocusState(): TerminalFocusState { 21: return focusState 22: } 23: export function subscribeTerminalFocus(cb: () => void): () => void { 24: subscribers.add(cb) 25: return () => { 26: subscribers.delete(cb) 27: } 28: } 29: export function resetTerminalFocusState(): void { 30: focusState = 'unknown' 31: for (const cb of subscribers) { 32: cb() 33: } 34: }

File: src/ink/terminal-querier.ts

typescript 1: import type { TerminalResponse } from './parse-keypress.js' 2: import { csi } from './termio/csi.js' 3: import { osc } from './termio/osc.js' 4: export type TerminalQuery<T extends TerminalResponse = TerminalResponse> = { 5: request: string 6: match: (r: TerminalResponse) => r is T 7: } 8: type DecrpmResponse = Extract<TerminalResponse, { type: 'decrpm' }> 9: type Da1Response = Extract<TerminalResponse, { type: 'da1' }> 10: type Da2Response = Extract<TerminalResponse, { type: 'da2' }> 11: type KittyResponse = Extract<TerminalResponse, { type: 'kittyKeyboard' }> 12: type CursorPosResponse = Extract<TerminalResponse, { type: 'cursorPosition' }> 13: type OscResponse = Extract<TerminalResponse, { type: 'osc' }> 14: type XtversionResponse = Extract<TerminalResponse, { type: 'xtversion' }> 15: export function decrqm(mode: number): TerminalQuery<DecrpmResponse> { 16: return { 17: request: csi(`?${mode}$p`), 18: match: (r): r is DecrpmResponse => r.type === 'decrpm' && r.mode === mode, 19: } 20: } 21: export function da1(): TerminalQuery<Da1Response> { 22: return { 23: request: csi('c'), 24: match: (r): r is Da1Response => r.type === 'da1', 25: } 26: } 27: export function da2(): TerminalQuery<Da2Response> { 28: return { 29: request: csi('>c'), 30: match: (r): r is Da2Response => r.type === 'da2', 31: } 32: } 33: export function kittyKeyboard(): TerminalQuery<KittyResponse> { 34: return { 35: request: csi('?u'), 36: match: (r): r is KittyResponse => r.type === 'kittyKeyboard', 37: } 38: } 39: export function cursorPosition(): TerminalQuery<CursorPosResponse> { 40: return { 41: request: csi('?6n'), 42: match: (r): r is CursorPosResponse => r.type === 'cursorPosition', 43: } 44: } 45: export function oscColor(code: number): TerminalQuery<OscResponse> { 46: return { 47: request: osc(code, '?'), 48: match: (r): r is OscResponse => r.type === 'osc' && r.code === code, 49: } 50: } 51: export function xtversion(): TerminalQuery<XtversionResponse> { 52: return { 53: request: csi('>0q'), 54: match: (r): r is XtversionResponse => r.type === 'xtversion', 55: } 56: } 57: const SENTINEL = csi('c') 58: type Pending = 59: | { 60: kind: 'query' 61: match: (r: TerminalResponse) => boolean 62: resolve: (r: TerminalResponse | undefined) => void 63: } 64: | { kind: 'sentinel'; resolve: () => void } 65: export class TerminalQuerier { 66: private queue: Pending[] = [] 67: constructor(private stdout: NodeJS.WriteStream) {} 68: send<T extends TerminalResponse>( 69: query: TerminalQuery<T>, 70: ): Promise<T | undefined> { 71: return new Promise(resolve => { 72: this.queue.push({ 73: kind: 'query', 74: match: query.match, 75: resolve: r => resolve(r as T | undefined), 76: }) 77: this.stdout.write(query.request) 78: }) 79: } 80: flush(): Promise<void> { 81: return new Promise(resolve => { 82: this.queue.push({ kind: 'sentinel', resolve }) 83: this.stdout.write(SENTINEL) 84: }) 85: } 86: onResponse(r: TerminalResponse): void { 87: const idx = this.queue.findIndex(p => p.kind === 'query' && p.match(r)) 88: if (idx !== -1) { 89: const [q] = this.queue.splice(idx, 1) 90: if (q?.kind === 'query') q.resolve(r) 91: return 92: } 93: if (r.type === 'da1') { 94: const s = this.queue.findIndex(p => p.kind === 'sentinel') 95: if (s === -1) return 96: for (const p of this.queue.splice(0, s + 1)) { 97: if (p.kind === 'query') p.resolve(undefined) 98: else p.resolve() 99: } 100: } 101: } 102: }

File: src/ink/terminal.ts

typescript 1: import { coerce } from 'semver' 2: import type { Writable } from 'stream' 3: import { env } from '../utils/env.js' 4: import { gte } from '../utils/semver.js' 5: import { getClearTerminalSequence } from './clearTerminal.js' 6: import type { Diff } from './frame.js' 7: import { cursorMove, cursorTo, eraseLines } from './termio/csi.js' 8: import { BSU, ESU, HIDE_CURSOR, SHOW_CURSOR } from './termio/dec.js' 9: import { link } from './termio/osc.js' 10: export type Progress = { 11: state: 'running' | 'completed' | 'error' | 'indeterminate' 12: percentage?: number 13: } 14: export function isProgressReportingAvailable(): boolean { 15: if (!process.stdout.isTTY) { 16: return false 17: } 18: if (process.env.WT_SESSION) { 19: return false 20: } 21: if ( 22: process.env.ConEmuANSI || 23: process.env.ConEmuPID || 24: process.env.ConEmuTask 25: ) { 26: return true 27: } 28: const version = coerce(process.env.TERM_PROGRAM_VERSION) 29: if (!version) { 30: return false 31: } 32: if (process.env.TERM_PROGRAM === 'ghostty') { 33: return gte(version.version, '1.2.0') 34: } 35: if (process.env.TERM_PROGRAM === 'iTerm.app') { 36: return gte(version.version, '3.6.6') 37: } 38: return false 39: } 40: export function isSynchronizedOutputSupported(): boolean { 41: if (process.env.TMUX) return false 42: const termProgram = process.env.TERM_PROGRAM 43: const term = process.env.TERM 44: if ( 45: termProgram === 'iTerm.app' || 46: termProgram === 'WezTerm' || 47: termProgram === 'WarpTerminal' || 48: termProgram === 'ghostty' || 49: termProgram === 'contour' || 50: termProgram === 'vscode' || 51: termProgram === 'alacritty' 52: ) { 53: return true 54: } 55: if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true 56: if (term === 'xterm-ghostty') return true 57: if (term?.startsWith('foot')) return true 58: if (term?.includes('alacritty')) return true 59: if (process.env.ZED_TERM) return true 60: if (process.env.WT_SESSION) return true 61: const vteVersion = process.env.VTE_VERSION 62: if (vteVersion) { 63: const version = parseInt(vteVersion, 10) 64: if (version >= 6800) return true 65: } 66: return false 67: } 68: let xtversionName: string | undefined 69: export function setXtversionName(name: string): void { 70: if (xtversionName === undefined) xtversionName = name 71: } 72: export function isXtermJs(): boolean { 73: if (process.env.TERM_PROGRAM === 'vscode') return true 74: return xtversionName?.startsWith('xterm.js') ?? false 75: } 76: const EXTENDED_KEYS_TERMINALS = [ 77: 'iTerm.app', 78: 'kitty', 79: 'WezTerm', 80: 'ghostty', 81: 'tmux', 82: 'windows-terminal', 83: ] 84: export function supportsExtendedKeys(): boolean { 85: return EXTENDED_KEYS_TERMINALS.includes(env.terminal ?? '') 86: } 87: /** True if the terminal scrolls the viewport when it receives cursor-up 88: * sequences that reach above the visible area. On Windows, conhost's 89: * SetConsoleCursorPosition follows the cursor into scrollback 90: * (microsoft/terminal#14774), yanking users to the top of their buffer 91: * mid-stream. WT_SESSION catches WSL-in-Windows-Terminal where platform 92: * is linux but output still routes through conhost. */ 93: export function hasCursorUpViewportYankBug(): boolean { 94: return process.platform === 'win32' || !!process.env.WT_SESSION 95: } 96: export const SYNC_OUTPUT_SUPPORTED = isSynchronizedOutputSupported() 97: export type Terminal = { 98: stdout: Writable 99: stderr: Writable 100: } 101: export function writeDiffToTerminal( 102: terminal: Terminal, 103: diff: Diff, 104: skipSyncMarkers = false, 105: ): void { 106: if (diff.length === 0) { 107: return 108: } 109: const useSync = !skipSyncMarkers 110: let buffer = useSync ? BSU : '' 111: for (const patch of diff) { 112: switch (patch.type) { 113: case 'stdout': 114: buffer += patch.content 115: break 116: case 'clear': 117: if (patch.count > 0) { 118: buffer += eraseLines(patch.count) 119: } 120: break 121: case 'clearTerminal': 122: buffer += getClearTerminalSequence() 123: break 124: case 'cursorHide': 125: buffer += HIDE_CURSOR 126: break 127: case 'cursorShow': 128: buffer += SHOW_CURSOR 129: break 130: case 'cursorMove': 131: buffer += cursorMove(patch.x, patch.y) 132: break 133: case 'cursorTo': 134: buffer += cursorTo(patch.col) 135: break 136: case 'carriageReturn': 137: buffer += '\r' 138: break 139: case 'hyperlink': 140: buffer += link(patch.uri) 141: break 142: case 'styleStr': 143: buffer += patch.str 144: break 145: } 146: } 147: if (useSync) buffer += ESU 148: terminal.stdout.write(buffer) 149: }

File: src/ink/termio.ts

typescript 1: export { Parser } from './termio/parser.js' 2: export type { 3: Action, 4: Color, 5: CursorAction, 6: CursorDirection, 7: EraseAction, 8: Grapheme, 9: LinkAction, 10: ModeAction, 11: NamedColor, 12: ScrollAction, 13: TextSegment, 14: TextStyle, 15: TitleAction, 16: UnderlineStyle, 17: } from './termio/types.js' 18: export { colorsEqual, defaultStyle, stylesEqual } from './termio/types.js'

File: src/ink/useTerminalNotification.ts

typescript 1: import { createContext, useCallback, useContext, useMemo } from 'react' 2: import { isProgressReportingAvailable, type Progress } from './terminal.js' 3: import { BEL } from './termio/ansi.js' 4: import { ITERM2, OSC, osc, PROGRESS, wrapForMultiplexer } from './termio/osc.js' 5: type WriteRaw = (data: string) => void 6: export const TerminalWriteContext = createContext<WriteRaw | null>(null) 7: export const TerminalWriteProvider = TerminalWriteContext.Provider 8: export type TerminalNotification = { 9: notifyITerm2: (opts: { message: string; title?: string }) => void 10: notifyKitty: (opts: { message: string; title: string; id: number }) => void 11: notifyGhostty: (opts: { message: string; title: string }) => void 12: notifyBell: () => void 13: progress: (state: Progress['state'] | null, percentage?: number) => void 14: } 15: export function useTerminalNotification(): TerminalNotification { 16: const writeRaw = useContext(TerminalWriteContext) 17: if (!writeRaw) { 18: throw new Error( 19: 'useTerminalNotification must be used within TerminalWriteProvider', 20: ) 21: } 22: const notifyITerm2 = useCallback( 23: ({ message, title }: { message: string; title?: string }) => { 24: const displayString = title ? `${title}:\n${message}` : message 25: writeRaw(wrapForMultiplexer(osc(OSC.ITERM2, `\n\n${displayString}`))) 26: }, 27: [writeRaw], 28: ) 29: const notifyKitty = useCallback( 30: ({ 31: message, 32: title, 33: id, 34: }: { 35: message: string 36: title: string 37: id: number 38: }) => { 39: writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=0:p=title`, title))) 40: writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:p=body`, message))) 41: writeRaw(wrapForMultiplexer(osc(OSC.KITTY, `i=${id}:d=1:a=focus`, ''))) 42: }, 43: [writeRaw], 44: ) 45: const notifyGhostty = useCallback( 46: ({ message, title }: { message: string; title: string }) => { 47: writeRaw(wrapForMultiplexer(osc(OSC.GHOSTTY, 'notify', title, message))) 48: }, 49: [writeRaw], 50: ) 51: const notifyBell = useCallback(() => { 52: writeRaw(BEL) 53: }, [writeRaw]) 54: const progress = useCallback( 55: (state: Progress['state'] | null, percentage?: number) => { 56: if (!isProgressReportingAvailable()) { 57: return 58: } 59: if (!state) { 60: writeRaw( 61: wrapForMultiplexer( 62: osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''), 63: ), 64: ) 65: return 66: } 67: const pct = Math.max(0, Math.min(100, Math.round(percentage ?? 0))) 68: switch (state) { 69: case 'completed': 70: writeRaw( 71: wrapForMultiplexer( 72: osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.CLEAR, ''), 73: ), 74: ) 75: break 76: case 'error': 77: writeRaw( 78: wrapForMultiplexer( 79: osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.ERROR, pct), 80: ), 81: ) 82: break 83: case 'indeterminate': 84: writeRaw( 85: wrapForMultiplexer( 86: osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.INDETERMINATE, ''), 87: ), 88: ) 89: break 90: case 'running': 91: writeRaw( 92: wrapForMultiplexer( 93: osc(OSC.ITERM2, ITERM2.PROGRESS, PROGRESS.SET, pct), 94: ), 95: ) 96: break 97: case null: 98: break 99: } 100: }, 101: [writeRaw], 102: ) 103: return useMemo( 104: () => ({ notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress }), 105: [notifyITerm2, notifyKitty, notifyGhostty, notifyBell, progress], 106: ) 107: }

File: src/ink/warn.ts

typescript 1: import { logForDebugging } from '../utils/debug.js' 2: export function ifNotInteger(value: number | undefined, name: string): void { 3: if (value === undefined) return 4: if (Number.isInteger(value)) return 5: logForDebugging(`${name} should be an integer, got ${value}`, { 6: level: 'warn', 7: }) 8: }

File: src/ink/widest-line.ts

typescript 1: import { lineWidth } from './line-width-cache.js' 2: export function widestLine(string: string): number { 3: let maxWidth = 0 4: let start = 0 5: while (start <= string.length) { 6: const end = string.indexOf('\n', start) 7: const line = 8: end === -1 ? string.substring(start) : string.substring(start, end) 9: maxWidth = Math.max(maxWidth, lineWidth(line)) 10: if (end === -1) break 11: start = end + 1 12: } 13: return maxWidth 14: }

File: src/ink/wrap-text.ts

typescript 1: import sliceAnsi from '../utils/sliceAnsi.js' 2: import { stringWidth } from './stringWidth.js' 3: import type { Styles } from './styles.js' 4: import { wrapAnsi } from './wrapAnsi.js' 5: const ELLIPSIS = '…' 6: function sliceFit(text: string, start: number, end: number): string { 7: const s = sliceAnsi(text, start, end) 8: return stringWidth(s) > end - start ? sliceAnsi(text, start, end - 1) : s 9: } 10: function truncate( 11: text: string, 12: columns: number, 13: position: 'start' | 'middle' | 'end', 14: ): string { 15: if (columns < 1) return '' 16: if (columns === 1) return ELLIPSIS 17: const length = stringWidth(text) 18: if (length <= columns) return text 19: if (position === 'start') { 20: return ELLIPSIS + sliceFit(text, length - columns + 1, length) 21: } 22: if (position === 'middle') { 23: const half = Math.floor(columns / 2) 24: return ( 25: sliceFit(text, 0, half) + 26: ELLIPSIS + 27: sliceFit(text, length - (columns - half) + 1, length) 28: ) 29: } 30: return sliceFit(text, 0, columns - 1) + ELLIPSIS 31: } 32: export default function wrapText( 33: text: string, 34: maxWidth: number, 35: wrapType: Styles['textWrap'], 36: ): string { 37: if (wrapType === 'wrap') { 38: return wrapAnsi(text, maxWidth, { 39: trim: false, 40: hard: true, 41: }) 42: } 43: if (wrapType === 'wrap-trim') { 44: return wrapAnsi(text, maxWidth, { 45: trim: true, 46: hard: true, 47: }) 48: } 49: if (wrapType!.startsWith('truncate')) { 50: let position: 'end' | 'middle' | 'start' = 'end' 51: if (wrapType === 'truncate-middle') { 52: position = 'middle' 53: } 54: if (wrapType === 'truncate-start') { 55: position = 'start' 56: } 57: return truncate(text, maxWidth, position) 58: } 59: return text 60: }

File: src/ink/wrapAnsi.ts

typescript 1: import wrapAnsiNpm from 'wrap-ansi' 2: type WrapAnsiOptions = { 3: hard?: boolean 4: wordWrap?: boolean 5: trim?: boolean 6: } 7: const wrapAnsiBun = 8: typeof Bun !== 'undefined' && typeof Bun.wrapAnsi === 'function' 9: ? Bun.wrapAnsi 10: : null 11: const wrapAnsi: ( 12: input: string, 13: columns: number, 14: options?: WrapAnsiOptions, 15: ) => string = wrapAnsiBun ?? wrapAnsiNpm 16: export { wrapAnsi }

File: src/keybindings/defaultBindings.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { satisfies } from 'src/utils/semver.js' 3: import { isRunningWithBun } from '../utils/bundledMode.js' 4: import { getPlatform } from '../utils/platform.js' 5: import type { KeybindingBlock } from './types.js' 6: const IMAGE_PASTE_KEY = getPlatform() === 'windows' ? 'alt+v' : 'ctrl+v' 7: const SUPPORTS_TERMINAL_VT_MODE = 8: getPlatform() !== 'windows' || 9: (isRunningWithBun() 10: ? satisfies(process.versions.bun, '>=1.2.23') 11: : satisfies(process.versions.node, '>=22.17.0 <23.0.0 || >=24.2.0')) 12: const MODE_CYCLE_KEY = SUPPORTS_TERMINAL_VT_MODE ? 'shift+tab' : 'meta+m' 13: export const DEFAULT_BINDINGS: KeybindingBlock[] = [ 14: { 15: context: 'Global', 16: bindings: { 17: 'ctrl+c': 'app:interrupt', 18: 'ctrl+d': 'app:exit', 19: 'ctrl+l': 'app:redraw', 20: 'ctrl+t': 'app:toggleTodos', 21: 'ctrl+o': 'app:toggleTranscript', 22: ...(feature('KAIROS') || feature('KAIROS_BRIEF') 23: ? { 'ctrl+shift+b': 'app:toggleBrief' as const } 24: : {}), 25: 'ctrl+shift+o': 'app:toggleTeammatePreview', 26: 'ctrl+r': 'history:search', 27: ...(feature('QUICK_SEARCH') 28: ? { 29: 'ctrl+shift+f': 'app:globalSearch' as const, 30: 'cmd+shift+f': 'app:globalSearch' as const, 31: 'ctrl+shift+p': 'app:quickOpen' as const, 32: 'cmd+shift+p': 'app:quickOpen' as const, 33: } 34: : {}), 35: ...(feature('TERMINAL_PANEL') ? { 'meta+j': 'app:toggleTerminal' } : {}), 36: }, 37: }, 38: { 39: context: 'Chat', 40: bindings: { 41: escape: 'chat:cancel', 42: 'ctrl+x ctrl+k': 'chat:killAgents', 43: [MODE_CYCLE_KEY]: 'chat:cycleMode', 44: 'meta+p': 'chat:modelPicker', 45: 'meta+o': 'chat:fastMode', 46: 'meta+t': 'chat:thinkingToggle', 47: enter: 'chat:submit', 48: up: 'history:previous', 49: down: 'history:next', 50: 'ctrl+_': 'chat:undo', 51: 'ctrl+shift+-': 'chat:undo', 52: 'ctrl+x ctrl+e': 'chat:externalEditor', 53: 'ctrl+g': 'chat:externalEditor', 54: 'ctrl+s': 'chat:stash', 55: [IMAGE_PASTE_KEY]: 'chat:imagePaste', 56: ...(feature('MESSAGE_ACTIONS') 57: ? { 'shift+up': 'chat:messageActions' as const } 58: : {}), 59: ...(feature('VOICE_MODE') ? { space: 'voice:pushToTalk' } : {}), 60: }, 61: }, 62: { 63: context: 'Autocomplete', 64: bindings: { 65: tab: 'autocomplete:accept', 66: escape: 'autocomplete:dismiss', 67: up: 'autocomplete:previous', 68: down: 'autocomplete:next', 69: }, 70: }, 71: { 72: context: 'Settings', 73: bindings: { 74: escape: 'confirm:no', 75: up: 'select:previous', 76: down: 'select:next', 77: k: 'select:previous', 78: j: 'select:next', 79: 'ctrl+p': 'select:previous', 80: 'ctrl+n': 'select:next', 81: space: 'select:accept', 82: enter: 'settings:close', 83: '/': 'settings:search', 84: r: 'settings:retry', 85: }, 86: }, 87: { 88: context: 'Confirmation', 89: bindings: { 90: y: 'confirm:yes', 91: n: 'confirm:no', 92: enter: 'confirm:yes', 93: escape: 'confirm:no', 94: up: 'confirm:previous', 95: down: 'confirm:next', 96: tab: 'confirm:nextField', 97: space: 'confirm:toggle', 98: 'shift+tab': 'confirm:cycleMode', 99: 'ctrl+e': 'confirm:toggleExplanation', 100: 'ctrl+d': 'permission:toggleDebug', 101: }, 102: }, 103: { 104: context: 'Tabs', 105: bindings: { 106: tab: 'tabs:next', 107: 'shift+tab': 'tabs:previous', 108: right: 'tabs:next', 109: left: 'tabs:previous', 110: }, 111: }, 112: { 113: context: 'Transcript', 114: bindings: { 115: 'ctrl+e': 'transcript:toggleShowAll', 116: 'ctrl+c': 'transcript:exit', 117: escape: 'transcript:exit', 118: q: 'transcript:exit', 119: }, 120: }, 121: { 122: context: 'HistorySearch', 123: bindings: { 124: 'ctrl+r': 'historySearch:next', 125: escape: 'historySearch:accept', 126: tab: 'historySearch:accept', 127: 'ctrl+c': 'historySearch:cancel', 128: enter: 'historySearch:execute', 129: }, 130: }, 131: { 132: context: 'Task', 133: bindings: { 134: 'ctrl+b': 'task:background', 135: }, 136: }, 137: { 138: context: 'ThemePicker', 139: bindings: { 140: 'ctrl+t': 'theme:toggleSyntaxHighlighting', 141: }, 142: }, 143: { 144: context: 'Scroll', 145: bindings: { 146: pageup: 'scroll:pageUp', 147: pagedown: 'scroll:pageDown', 148: wheelup: 'scroll:lineUp', 149: wheeldown: 'scroll:lineDown', 150: 'ctrl+home': 'scroll:top', 151: 'ctrl+end': 'scroll:bottom', 152: 'ctrl+shift+c': 'selection:copy', 153: 'cmd+c': 'selection:copy', 154: }, 155: }, 156: { 157: context: 'Help', 158: bindings: { 159: escape: 'help:dismiss', 160: }, 161: }, 162: { 163: context: 'Attachments', 164: bindings: { 165: right: 'attachments:next', 166: left: 'attachments:previous', 167: backspace: 'attachments:remove', 168: delete: 'attachments:remove', 169: down: 'attachments:exit', 170: escape: 'attachments:exit', 171: }, 172: }, 173: { 174: context: 'Footer', 175: bindings: { 176: up: 'footer:up', 177: 'ctrl+p': 'footer:up', 178: down: 'footer:down', 179: 'ctrl+n': 'footer:down', 180: right: 'footer:next', 181: left: 'footer:previous', 182: enter: 'footer:openSelected', 183: escape: 'footer:clearSelection', 184: }, 185: }, 186: { 187: context: 'MessageSelector', 188: bindings: { 189: up: 'messageSelector:up', 190: down: 'messageSelector:down', 191: k: 'messageSelector:up', 192: j: 'messageSelector:down', 193: 'ctrl+p': 'messageSelector:up', 194: 'ctrl+n': 'messageSelector:down', 195: 'ctrl+up': 'messageSelector:top', 196: 'shift+up': 'messageSelector:top', 197: 'meta+up': 'messageSelector:top', 198: 'shift+k': 'messageSelector:top', 199: 'ctrl+down': 'messageSelector:bottom', 200: 'shift+down': 'messageSelector:bottom', 201: 'meta+down': 'messageSelector:bottom', 202: 'shift+j': 'messageSelector:bottom', 203: enter: 'messageSelector:select', 204: }, 205: }, 206: ...(feature('MESSAGE_ACTIONS') 207: ? [ 208: { 209: context: 'MessageActions' as const, 210: bindings: { 211: up: 'messageActions:prev' as const, 212: down: 'messageActions:next' as const, 213: k: 'messageActions:prev' as const, 214: j: 'messageActions:next' as const, 215: 'meta+up': 'messageActions:top' as const, 216: 'meta+down': 'messageActions:bottom' as const, 217: 'super+up': 'messageActions:top' as const, 218: 'super+down': 'messageActions:bottom' as const, 219: 'shift+up': 'messageActions:prevUser' as const, 220: 'shift+down': 'messageActions:nextUser' as const, 221: escape: 'messageActions:escape' as const, 222: 'ctrl+c': 'messageActions:ctrlc' as const, 223: enter: 'messageActions:enter' as const, 224: c: 'messageActions:c' as const, 225: p: 'messageActions:p' as const, 226: }, 227: }, 228: ] 229: : []), 230: { 231: context: 'DiffDialog', 232: bindings: { 233: escape: 'diff:dismiss', 234: left: 'diff:previousSource', 235: right: 'diff:nextSource', 236: up: 'diff:previousFile', 237: down: 'diff:nextFile', 238: enter: 'diff:viewDetails', 239: }, 240: }, 241: { 242: context: 'ModelPicker', 243: bindings: { 244: left: 'modelPicker:decreaseEffort', 245: right: 'modelPicker:increaseEffort', 246: }, 247: }, 248: { 249: context: 'Select', 250: bindings: { 251: up: 'select:previous', 252: down: 'select:next', 253: j: 'select:next', 254: k: 'select:previous', 255: 'ctrl+n': 'select:next', 256: 'ctrl+p': 'select:previous', 257: enter: 'select:accept', 258: escape: 'select:cancel', 259: }, 260: }, 261: { 262: context: 'Plugin', 263: bindings: { 264: space: 'plugin:toggle', 265: i: 'plugin:install', 266: }, 267: }, 268: ]

File: src/keybindings/KeybindingContext.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react'; 3: import type { Key } from '../ink.js'; 4: import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js'; 5: import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; 6: type HandlerRegistration = { 7: action: string; 8: context: KeybindingContextName; 9: handler: () => void; 10: }; 11: type KeybindingContextValue = { 12: resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult; 13: setPendingChord: (pending: ParsedKeystroke[] | null) => void; 14: getDisplayText: (action: string, context: KeybindingContextName) => string | undefined; 15: bindings: ParsedBinding[]; 16: pendingChord: ParsedKeystroke[] | null; 17: activeContexts: Set<KeybindingContextName>; 18: registerActiveContext: (context: KeybindingContextName) => void; 19: unregisterActiveContext: (context: KeybindingContextName) => void; 20: registerHandler: (registration: HandlerRegistration) => () => void; 21: invokeAction: (action: string) => boolean; 22: }; 23: const KeybindingContext = createContext<KeybindingContextValue | null>(null); 24: type ProviderProps = { 25: bindings: ParsedBinding[]; 26: pendingChordRef: RefObject<ParsedKeystroke[] | null>; 27: pendingChord: ParsedKeystroke[] | null; 28: setPendingChord: (pending: ParsedKeystroke[] | null) => void; 29: activeContexts: Set<KeybindingContextName>; 30: registerActiveContext: (context: KeybindingContextName) => void; 31: unregisterActiveContext: (context: KeybindingContextName) => void; 32: handlerRegistryRef: RefObject<Map<string, Set<HandlerRegistration>>>; 33: children: React.ReactNode; 34: }; 35: export function KeybindingProvider(t0) { 36: const $ = _c(24); 37: const { 38: bindings, 39: pendingChordRef, 40: pendingChord, 41: setPendingChord, 42: activeContexts, 43: registerActiveContext, 44: unregisterActiveContext, 45: handlerRegistryRef, 46: children 47: } = t0; 48: let t1; 49: if ($[0] !== bindings) { 50: t1 = (action, context) => getBindingDisplayText(action, context, bindings); 51: $[0] = bindings; 52: $[1] = t1; 53: } else { 54: t1 = $[1]; 55: } 56: const getDisplay = t1; 57: let t2; 58: if ($[2] !== handlerRegistryRef) { 59: t2 = registration => { 60: const registry = handlerRegistryRef.current; 61: if (!registry) { 62: return _temp; 63: } 64: if (!registry.has(registration.action)) { 65: registry.set(registration.action, new Set()); 66: } 67: registry.get(registration.action).add(registration); 68: return () => { 69: const handlers = registry.get(registration.action); 70: if (handlers) { 71: handlers.delete(registration); 72: if (handlers.size === 0) { 73: registry.delete(registration.action); 74: } 75: } 76: }; 77: }; 78: $[2] = handlerRegistryRef; 79: $[3] = t2; 80: } else { 81: t2 = $[3]; 82: } 83: const registerHandler = t2; 84: let t3; 85: if ($[4] !== activeContexts || $[5] !== handlerRegistryRef) { 86: t3 = action_0 => { 87: const registry_0 = handlerRegistryRef.current; 88: if (!registry_0) { 89: return false; 90: } 91: const handlers_0 = registry_0.get(action_0); 92: if (!handlers_0 || handlers_0.size === 0) { 93: return false; 94: } 95: for (const registration_0 of handlers_0) { 96: if (activeContexts.has(registration_0.context)) { 97: registration_0.handler(); 98: return true; 99: } 100: } 101: return false; 102: }; 103: $[4] = activeContexts; 104: $[5] = handlerRegistryRef; 105: $[6] = t3; 106: } else { 107: t3 = $[6]; 108: } 109: const invokeAction = t3; 110: let t4; 111: if ($[7] !== bindings || $[8] !== pendingChordRef) { 112: t4 = (input, key, contexts) => resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); 113: $[7] = bindings; 114: $[8] = pendingChordRef; 115: $[9] = t4; 116: } else { 117: t4 = $[9]; 118: } 119: let t5; 120: if ($[10] !== activeContexts || $[11] !== bindings || $[12] !== getDisplay || $[13] !== invokeAction || $[14] !== pendingChord || $[15] !== registerActiveContext || $[16] !== registerHandler || $[17] !== setPendingChord || $[18] !== t4 || $[19] !== unregisterActiveContext) { 121: t5 = { 122: resolve: t4, 123: setPendingChord, 124: getDisplayText: getDisplay, 125: bindings, 126: pendingChord, 127: activeContexts, 128: registerActiveContext, 129: unregisterActiveContext, 130: registerHandler, 131: invokeAction 132: }; 133: $[10] = activeContexts; 134: $[11] = bindings; 135: $[12] = getDisplay; 136: $[13] = invokeAction; 137: $[14] = pendingChord; 138: $[15] = registerActiveContext; 139: $[16] = registerHandler; 140: $[17] = setPendingChord; 141: $[18] = t4; 142: $[19] = unregisterActiveContext; 143: $[20] = t5; 144: } else { 145: t5 = $[20]; 146: } 147: const value = t5; 148: let t6; 149: if ($[21] !== children || $[22] !== value) { 150: t6 = <KeybindingContext.Provider value={value}>{children}</KeybindingContext.Provider>; 151: $[21] = children; 152: $[22] = value; 153: $[23] = t6; 154: } else { 155: t6 = $[23]; 156: } 157: return t6; 158: } 159: function _temp() {} 160: export function useKeybindingContext() { 161: const ctx = useContext(KeybindingContext); 162: if (!ctx) { 163: throw new Error("useKeybindingContext must be used within KeybindingProvider"); 164: } 165: return ctx; 166: } 167: export function useOptionalKeybindingContext() { 168: return useContext(KeybindingContext); 169: } 170: export function useRegisterKeybindingContext(context, t0) { 171: const $ = _c(5); 172: const isActive = t0 === undefined ? true : t0; 173: const keybindingContext = useOptionalKeybindingContext(); 174: let t1; 175: let t2; 176: if ($[0] !== context || $[1] !== isActive || $[2] !== keybindingContext) { 177: t1 = () => { 178: if (!keybindingContext || !isActive) { 179: return; 180: } 181: keybindingContext.registerActiveContext(context); 182: return () => { 183: keybindingContext.unregisterActiveContext(context); 184: }; 185: }; 186: t2 = [context, keybindingContext, isActive]; 187: $[0] = context; 188: $[1] = isActive; 189: $[2] = keybindingContext; 190: $[3] = t1; 191: $[4] = t2; 192: } else { 193: t1 = $[3]; 194: t2 = $[4]; 195: } 196: useLayoutEffect(t1, t2); 197: }

File: src/keybindings/KeybindingProviderSetup.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useCallback, useEffect, useRef, useState } from 'react'; 3: import { useNotifications } from '../context/notifications.js'; 4: import type { InputEvent } from '../ink/events/input-event.js'; 5: import { type Key, useInput } from '../ink.js'; 6: import { count } from '../utils/array.js'; 7: import { logForDebugging } from '../utils/debug.js'; 8: import { plural } from '../utils/stringUtils.js'; 9: import { KeybindingProvider } from './KeybindingContext.js'; 10: import { initializeKeybindingWatcher, type KeybindingsLoadResult, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges } from './loadUserBindings.js'; 11: import { resolveKeyWithChordState } from './resolver.js'; 12: import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; 13: import type { KeybindingWarning } from './validate.js'; 14: const CHORD_TIMEOUT_MS = 1000; 15: type Props = { 16: children: React.ReactNode; 17: }; 18: function useKeybindingWarnings(warnings, isReload) { 19: const $ = _c(9); 20: const { 21: addNotification, 22: removeNotification 23: } = useNotifications(); 24: let t0; 25: if ($[0] !== addNotification || $[1] !== removeNotification || $[2] !== warnings) { 26: t0 = () => { 27: if (warnings.length === 0) { 28: removeNotification("keybinding-config-warning"); 29: return; 30: } 31: const errorCount = count(warnings, _temp); 32: const warnCount = count(warnings, _temp2); 33: let message; 34: if (errorCount > 0 && warnCount > 0) { 35: message = `Found ${errorCount} keybinding ${plural(errorCount, "error")} and ${warnCount} ${plural(warnCount, "warning")}`; 36: } else { 37: if (errorCount > 0) { 38: message = `Found ${errorCount} keybinding ${plural(errorCount, "error")}`; 39: } else { 40: message = `Found ${warnCount} keybinding ${plural(warnCount, "warning")}`; 41: } 42: } 43: message = message + " \xB7 /doctor for details"; 44: addNotification({ 45: key: "keybinding-config-warning", 46: text: message, 47: color: errorCount > 0 ? "error" : "warning", 48: priority: errorCount > 0 ? "immediate" : "high", 49: timeoutMs: 60000 50: }); 51: }; 52: $[0] = addNotification; 53: $[1] = removeNotification; 54: $[2] = warnings; 55: $[3] = t0; 56: } else { 57: t0 = $[3]; 58: } 59: let t1; 60: if ($[4] !== addNotification || $[5] !== isReload || $[6] !== removeNotification || $[7] !== warnings) { 61: t1 = [warnings, isReload, addNotification, removeNotification]; 62: $[4] = addNotification; 63: $[5] = isReload; 64: $[6] = removeNotification; 65: $[7] = warnings; 66: $[8] = t1; 67: } else { 68: t1 = $[8]; 69: } 70: useEffect(t0, t1); 71: } 72: function _temp2(w_0) { 73: return w_0.severity === "warning"; 74: } 75: function _temp(w) { 76: return w.severity === "error"; 77: } 78: export function KeybindingSetup({ 79: children 80: }: Props): React.ReactNode { 81: const [{ 82: bindings, 83: warnings 84: }, setLoadResult] = useState<KeybindingsLoadResult>(() => { 85: const result = loadKeybindingsSyncWithWarnings(); 86: logForDebugging(`[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`); 87: return result; 88: }); 89: const [isReload, setIsReload] = useState(false); 90: useKeybindingWarnings(warnings, isReload); 91: const pendingChordRef = useRef<ParsedKeystroke[] | null>(null); 92: const [pendingChord, setPendingChordState] = useState<ParsedKeystroke[] | null>(null); 93: const chordTimeoutRef = useRef<NodeJS.Timeout | null>(null); 94: const handlerRegistryRef = useRef(new Map<string, Set<{ 95: action: string; 96: context: KeybindingContextName; 97: handler: () => void; 98: }>>()); 99: const activeContextsRef = useRef<Set<KeybindingContextName>>(new Set()); 100: const registerActiveContext = useCallback((context: KeybindingContextName) => { 101: activeContextsRef.current.add(context); 102: }, []); 103: const unregisterActiveContext = useCallback((context_0: KeybindingContextName) => { 104: activeContextsRef.current.delete(context_0); 105: }, []); 106: const clearChordTimeout = useCallback(() => { 107: if (chordTimeoutRef.current) { 108: clearTimeout(chordTimeoutRef.current); 109: chordTimeoutRef.current = null; 110: } 111: }, []); 112: const setPendingChord = useCallback((pending: ParsedKeystroke[] | null) => { 113: clearChordTimeout(); 114: if (pending !== null) { 115: chordTimeoutRef.current = setTimeout((pendingChordRef_0, setPendingChordState_0) => { 116: logForDebugging('[keybindings] Chord timeout - cancelling'); 117: pendingChordRef_0.current = null; 118: setPendingChordState_0(null); 119: }, CHORD_TIMEOUT_MS, pendingChordRef, setPendingChordState); 120: } 121: pendingChordRef.current = pending; 122: setPendingChordState(pending); 123: }, [clearChordTimeout]); 124: useEffect(() => { 125: void initializeKeybindingWatcher(); 126: const unsubscribe = subscribeToKeybindingChanges(result_0 => { 127: setIsReload(true); 128: setLoadResult(result_0); 129: logForDebugging(`[keybindings] Reloaded: ${result_0.bindings.length} bindings, ${result_0.warnings.length} warnings`); 130: }); 131: return () => { 132: unsubscribe(); 133: clearChordTimeout(); 134: }; 135: }, [clearChordTimeout]); 136: return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={pendingChord} setPendingChord={setPendingChord} activeContexts={activeContextsRef.current} registerActiveContext={registerActiveContext} unregisterActiveContext={unregisterActiveContext} handlerRegistryRef={handlerRegistryRef}> 137: <ChordInterceptor bindings={bindings} pendingChordRef={pendingChordRef} setPendingChord={setPendingChord} activeContexts={activeContextsRef.current} handlerRegistryRef={handlerRegistryRef} /> 138: {children} 139: </KeybindingProvider>; 140: } 141: type HandlerRegistration = { 142: action: string; 143: context: KeybindingContextName; 144: handler: () => void; 145: }; 146: function ChordInterceptor(t0) { 147: const $ = _c(6); 148: const { 149: bindings, 150: pendingChordRef, 151: setPendingChord, 152: activeContexts, 153: handlerRegistryRef 154: } = t0; 155: let t1; 156: if ($[0] !== activeContexts || $[1] !== bindings || $[2] !== handlerRegistryRef || $[3] !== pendingChordRef || $[4] !== setPendingChord) { 157: t1 = (input, key, event) => { 158: if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) { 159: return; 160: } 161: const registry = handlerRegistryRef.current; 162: const handlerContexts = new Set(); 163: if (registry) { 164: for (const handlers of registry.values()) { 165: for (const registration of handlers) { 166: handlerContexts.add(registration.context); 167: } 168: } 169: } 170: const contexts = [...handlerContexts, ...activeContexts, "Global"]; 171: const wasInChord = pendingChordRef.current !== null; 172: const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); 173: bb23: switch (result.type) { 174: case "chord_started": 175: { 176: setPendingChord(result.pending); 177: event.stopImmediatePropagation(); 178: break bb23; 179: } 180: case "match": 181: { 182: setPendingChord(null); 183: if (wasInChord) { 184: const contextsSet = new Set(contexts); 185: if (registry) { 186: const handlers_0 = registry.get(result.action); 187: if (handlers_0 && handlers_0.size > 0) { 188: for (const registration_0 of handlers_0) { 189: if (contextsSet.has(registration_0.context)) { 190: registration_0.handler(); 191: event.stopImmediatePropagation(); 192: break; 193: } 194: } 195: } 196: } 197: } 198: break bb23; 199: } 200: case "chord_cancelled": 201: { 202: setPendingChord(null); 203: event.stopImmediatePropagation(); 204: break bb23; 205: } 206: case "unbound": 207: { 208: setPendingChord(null); 209: event.stopImmediatePropagation(); 210: break bb23; 211: } 212: case "none": 213: } 214: }; 215: $[0] = activeContexts; 216: $[1] = bindings; 217: $[2] = handlerRegistryRef; 218: $[3] = pendingChordRef; 219: $[4] = setPendingChord; 220: $[5] = t1; 221: } else { 222: t1 = $[5]; 223: } 224: const handleInput = t1; 225: useInput(handleInput); 226: return null; 227: }

File: src/keybindings/loadUserBindings.ts

typescript 1: import chokidar, { type FSWatcher } from 'chokidar' 2: import { readFileSync } from 'fs' 3: import { readFile, stat } from 'fs/promises' 4: import { dirname, join } from 'path' 5: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 6: import { logEvent } from '../services/analytics/index.js' 7: import { registerCleanup } from '../utils/cleanupRegistry.js' 8: import { logForDebugging } from '../utils/debug.js' 9: import { getClaudeConfigHomeDir } from '../utils/envUtils.js' 10: import { errorMessage, isENOENT } from '../utils/errors.js' 11: import { createSignal } from '../utils/signal.js' 12: import { jsonParse } from '../utils/slowOperations.js' 13: import { DEFAULT_BINDINGS } from './defaultBindings.js' 14: import { parseBindings } from './parser.js' 15: import type { KeybindingBlock, ParsedBinding } from './types.js' 16: import { 17: checkDuplicateKeysInJson, 18: type KeybindingWarning, 19: validateBindings, 20: } from './validate.js' 21: export function isKeybindingCustomizationEnabled(): boolean { 22: return getFeatureValue_CACHED_MAY_BE_STALE( 23: 'tengu_keybinding_customization_release', 24: false, 25: ) 26: } 27: const FILE_STABILITY_THRESHOLD_MS = 500 28: const FILE_STABILITY_POLL_INTERVAL_MS = 200 29: export type KeybindingsLoadResult = { 30: bindings: ParsedBinding[] 31: warnings: KeybindingWarning[] 32: } 33: let watcher: FSWatcher | null = null 34: let initialized = false 35: let disposed = false 36: let cachedBindings: ParsedBinding[] | null = null 37: let cachedWarnings: KeybindingWarning[] = [] 38: const keybindingsChanged = createSignal<[result: KeybindingsLoadResult]>() 39: let lastCustomBindingsLogDate: string | null = null 40: function logCustomBindingsLoadedOncePerDay(userBindingCount: number): void { 41: const today = new Date().toISOString().slice(0, 10) 42: if (lastCustomBindingsLogDate === today) return 43: lastCustomBindingsLogDate = today 44: logEvent('tengu_custom_keybindings_loaded', { 45: user_binding_count: userBindingCount, 46: }) 47: } 48: function isKeybindingBlock(obj: unknown): obj is KeybindingBlock { 49: if (typeof obj !== 'object' || obj === null) return false 50: const b = obj as Record<string, unknown> 51: return ( 52: typeof b.context === 'string' && 53: typeof b.bindings === 'object' && 54: b.bindings !== null 55: ) 56: } 57: function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] { 58: return Array.isArray(arr) && arr.every(isKeybindingBlock) 59: } 60: export function getKeybindingsPath(): string { 61: return join(getClaudeConfigHomeDir(), 'keybindings.json') 62: } 63: function getDefaultParsedBindings(): ParsedBinding[] { 64: return parseBindings(DEFAULT_BINDINGS) 65: } 66: export async function loadKeybindings(): Promise<KeybindingsLoadResult> { 67: const defaultBindings = getDefaultParsedBindings() 68: if (!isKeybindingCustomizationEnabled()) { 69: return { bindings: defaultBindings, warnings: [] } 70: } 71: const userPath = getKeybindingsPath() 72: try { 73: const content = await readFile(userPath, 'utf-8') 74: const parsed: unknown = jsonParse(content) 75: let userBlocks: unknown 76: if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) { 77: userBlocks = (parsed as { bindings: unknown }).bindings 78: } else { 79: const errorMessage = 'keybindings.json must have a "bindings" array' 80: const suggestion = 'Use format: { "bindings": [ ... ] }' 81: logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`) 82: return { 83: bindings: defaultBindings, 84: warnings: [ 85: { 86: type: 'parse_error', 87: severity: 'error', 88: message: errorMessage, 89: suggestion, 90: }, 91: ], 92: } 93: } 94: if (!isKeybindingBlockArray(userBlocks)) { 95: const errorMessage = !Array.isArray(userBlocks) 96: ? '"bindings" must be an array' 97: : 'keybindings.json contains invalid block structure' 98: const suggestion = !Array.isArray(userBlocks) 99: ? 'Set "bindings" to an array of keybinding blocks' 100: : 'Each block must have "context" (string) and "bindings" (object)' 101: logForDebugging(`[keybindings] Invalid keybindings.json: ${errorMessage}`) 102: return { 103: bindings: defaultBindings, 104: warnings: [ 105: { 106: type: 'parse_error', 107: severity: 'error', 108: message: errorMessage, 109: suggestion, 110: }, 111: ], 112: } 113: } 114: const userParsed = parseBindings(userBlocks) 115: logForDebugging( 116: `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`, 117: ) 118: const mergedBindings = [...defaultBindings, ...userParsed] 119: logCustomBindingsLoadedOncePerDay(userParsed.length) 120: const duplicateKeyWarnings = checkDuplicateKeysInJson(content) 121: const warnings = [ 122: ...duplicateKeyWarnings, 123: ...validateBindings(userBlocks, mergedBindings), 124: ] 125: if (warnings.length > 0) { 126: logForDebugging( 127: `[keybindings] Found ${warnings.length} validation issue(s)`, 128: ) 129: } 130: return { bindings: mergedBindings, warnings } 131: } catch (error) { 132: if (isENOENT(error)) { 133: return { bindings: defaultBindings, warnings: [] } 134: } 135: logForDebugging( 136: `[keybindings] Error loading ${userPath}: ${errorMessage(error)}`, 137: ) 138: return { 139: bindings: defaultBindings, 140: warnings: [ 141: { 142: type: 'parse_error', 143: severity: 'error', 144: message: `Failed to parse keybindings.json: ${errorMessage(error)}`, 145: }, 146: ], 147: } 148: } 149: } 150: export function loadKeybindingsSync(): ParsedBinding[] { 151: if (cachedBindings) { 152: return cachedBindings 153: } 154: const result = loadKeybindingsSyncWithWarnings() 155: return result.bindings 156: } 157: export function loadKeybindingsSyncWithWarnings(): KeybindingsLoadResult { 158: if (cachedBindings) { 159: return { bindings: cachedBindings, warnings: cachedWarnings } 160: } 161: const defaultBindings = getDefaultParsedBindings() 162: if (!isKeybindingCustomizationEnabled()) { 163: cachedBindings = defaultBindings 164: cachedWarnings = [] 165: return { bindings: cachedBindings, warnings: cachedWarnings } 166: } 167: const userPath = getKeybindingsPath() 168: try { 169: const content = readFileSync(userPath, 'utf-8') 170: const parsed: unknown = jsonParse(content) 171: let userBlocks: unknown 172: if (typeof parsed === 'object' && parsed !== null && 'bindings' in parsed) { 173: userBlocks = (parsed as { bindings: unknown }).bindings 174: } else { 175: cachedBindings = defaultBindings 176: cachedWarnings = [ 177: { 178: type: 'parse_error', 179: severity: 'error', 180: message: 'keybindings.json must have a "bindings" array', 181: suggestion: 'Use format: { "bindings": [ ... ] }', 182: }, 183: ] 184: return { bindings: cachedBindings, warnings: cachedWarnings } 185: } 186: if (!isKeybindingBlockArray(userBlocks)) { 187: const errorMessage = !Array.isArray(userBlocks) 188: ? '"bindings" must be an array' 189: : 'keybindings.json contains invalid block structure' 190: const suggestion = !Array.isArray(userBlocks) 191: ? 'Set "bindings" to an array of keybinding blocks' 192: : 'Each block must have "context" (string) and "bindings" (object)' 193: cachedBindings = defaultBindings 194: cachedWarnings = [ 195: { 196: type: 'parse_error', 197: severity: 'error', 198: message: errorMessage, 199: suggestion, 200: }, 201: ] 202: return { bindings: cachedBindings, warnings: cachedWarnings } 203: } 204: const userParsed = parseBindings(userBlocks) 205: logForDebugging( 206: `[keybindings] Loaded ${userParsed.length} user bindings from ${userPath}`, 207: ) 208: cachedBindings = [...defaultBindings, ...userParsed] 209: logCustomBindingsLoadedOncePerDay(userParsed.length) 210: const duplicateKeyWarnings = checkDuplicateKeysInJson(content) 211: cachedWarnings = [ 212: ...duplicateKeyWarnings, 213: ...validateBindings(userBlocks, cachedBindings), 214: ] 215: if (cachedWarnings.length > 0) { 216: logForDebugging( 217: `[keybindings] Found ${cachedWarnings.length} validation issue(s)`, 218: ) 219: } 220: return { bindings: cachedBindings, warnings: cachedWarnings } 221: } catch { 222: cachedBindings = defaultBindings 223: cachedWarnings = [] 224: return { bindings: cachedBindings, warnings: cachedWarnings } 225: } 226: } 227: export async function initializeKeybindingWatcher(): Promise<void> { 228: if (initialized || disposed) return 229: if (!isKeybindingCustomizationEnabled()) { 230: logForDebugging( 231: '[keybindings] Skipping file watcher - user customization disabled', 232: ) 233: return 234: } 235: const userPath = getKeybindingsPath() 236: const watchDir = dirname(userPath) 237: try { 238: const stats = await stat(watchDir) 239: if (!stats.isDirectory()) { 240: logForDebugging( 241: `[keybindings] Not watching: ${watchDir} is not a directory`, 242: ) 243: return 244: } 245: } catch { 246: logForDebugging(`[keybindings] Not watching: ${watchDir} does not exist`) 247: return 248: } 249: initialized = true 250: logForDebugging(`[keybindings] Watching for changes to ${userPath}`) 251: watcher = chokidar.watch(userPath, { 252: persistent: true, 253: ignoreInitial: true, 254: awaitWriteFinish: { 255: stabilityThreshold: FILE_STABILITY_THRESHOLD_MS, 256: pollInterval: FILE_STABILITY_POLL_INTERVAL_MS, 257: }, 258: ignorePermissionErrors: true, 259: usePolling: false, 260: atomic: true, 261: }) 262: watcher.on('add', handleChange) 263: watcher.on('change', handleChange) 264: watcher.on('unlink', handleDelete) 265: registerCleanup(async () => disposeKeybindingWatcher()) 266: } 267: export function disposeKeybindingWatcher(): void { 268: disposed = true 269: if (watcher) { 270: void watcher.close() 271: watcher = null 272: } 273: keybindingsChanged.clear() 274: } 275: export const subscribeToKeybindingChanges = keybindingsChanged.subscribe 276: async function handleChange(path: string): Promise<void> { 277: logForDebugging(`[keybindings] Detected change to ${path}`) 278: try { 279: const result = await loadKeybindings() 280: cachedBindings = result.bindings 281: cachedWarnings = result.warnings 282: keybindingsChanged.emit(result) 283: } catch (error) { 284: logForDebugging(`[keybindings] Error reloading: ${errorMessage(error)}`) 285: } 286: } 287: function handleDelete(path: string): void { 288: logForDebugging(`[keybindings] Detected deletion of ${path}`) 289: const defaultBindings = getDefaultParsedBindings() 290: cachedBindings = defaultBindings 291: cachedWarnings = [] 292: keybindingsChanged.emit({ bindings: defaultBindings, warnings: [] }) 293: } 294: export function getCachedKeybindingWarnings(): KeybindingWarning[] { 295: return cachedWarnings 296: } 297: export function resetKeybindingLoaderForTesting(): void { 298: initialized = false 299: disposed = false 300: cachedBindings = null 301: cachedWarnings = [] 302: lastCustomBindingsLogDate = null 303: if (watcher) { 304: void watcher.close() 305: watcher = null 306: } 307: keybindingsChanged.clear() 308: }

File: src/keybindings/match.ts

typescript 1: import type { Key } from '../ink.js' 2: import type { ParsedBinding, ParsedKeystroke } from './types.js' 3: type InkModifiers = Pick<Key, 'ctrl' | 'shift' | 'meta' | 'super'> 4: function getInkModifiers(key: Key): InkModifiers { 5: return { 6: ctrl: key.ctrl, 7: shift: key.shift, 8: meta: key.meta, 9: super: key.super, 10: } 11: } 12: export function getKeyName(input: string, key: Key): string | null { 13: if (key.escape) return 'escape' 14: if (key.return) return 'enter' 15: if (key.tab) return 'tab' 16: if (key.backspace) return 'backspace' 17: if (key.delete) return 'delete' 18: if (key.upArrow) return 'up' 19: if (key.downArrow) return 'down' 20: if (key.leftArrow) return 'left' 21: if (key.rightArrow) return 'right' 22: if (key.pageUp) return 'pageup' 23: if (key.pageDown) return 'pagedown' 24: if (key.wheelUp) return 'wheelup' 25: if (key.wheelDown) return 'wheeldown' 26: if (key.home) return 'home' 27: if (key.end) return 'end' 28: if (input.length === 1) return input.toLowerCase() 29: return null 30: } 31: function modifiersMatch( 32: inkMods: InkModifiers, 33: target: ParsedKeystroke, 34: ): boolean { 35: if (inkMods.ctrl !== target.ctrl) return false 36: if (inkMods.shift !== target.shift) return false 37: const targetNeedsMeta = target.alt || target.meta 38: if (inkMods.meta !== targetNeedsMeta) return false 39: if (inkMods.super !== target.super) return false 40: return true 41: } 42: export function matchesKeystroke( 43: input: string, 44: key: Key, 45: target: ParsedKeystroke, 46: ): boolean { 47: const keyName = getKeyName(input, key) 48: if (keyName !== target.key) return false 49: const inkMods = getInkModifiers(key) 50: if (key.escape) { 51: return modifiersMatch({ ...inkMods, meta: false }, target) 52: } 53: return modifiersMatch(inkMods, target) 54: } 55: export function matchesBinding( 56: input: string, 57: key: Key, 58: binding: ParsedBinding, 59: ): boolean { 60: if (binding.chord.length !== 1) return false 61: const keystroke = binding.chord[0] 62: if (!keystroke) return false 63: return matchesKeystroke(input, key, keystroke) 64: }

File: src/keybindings/parser.ts

typescript 1: import type { 2: Chord, 3: KeybindingBlock, 4: ParsedBinding, 5: ParsedKeystroke, 6: } from './types.js' 7: export function parseKeystroke(input: string): ParsedKeystroke { 8: const parts = input.split('+') 9: const keystroke: ParsedKeystroke = { 10: key: '', 11: ctrl: false, 12: alt: false, 13: shift: false, 14: meta: false, 15: super: false, 16: } 17: for (const part of parts) { 18: const lower = part.toLowerCase() 19: switch (lower) { 20: case 'ctrl': 21: case 'control': 22: keystroke.ctrl = true 23: break 24: case 'alt': 25: case 'opt': 26: case 'option': 27: keystroke.alt = true 28: break 29: case 'shift': 30: keystroke.shift = true 31: break 32: case 'meta': 33: keystroke.meta = true 34: break 35: case 'cmd': 36: case 'command': 37: case 'super': 38: case 'win': 39: keystroke.super = true 40: break 41: case 'esc': 42: keystroke.key = 'escape' 43: break 44: case 'return': 45: keystroke.key = 'enter' 46: break 47: case 'space': 48: keystroke.key = ' ' 49: break 50: case '↑': 51: keystroke.key = 'up' 52: break 53: case '↓': 54: keystroke.key = 'down' 55: break 56: case '←': 57: keystroke.key = 'left' 58: break 59: case '→': 60: keystroke.key = 'right' 61: break 62: default: 63: keystroke.key = lower 64: break 65: } 66: } 67: return keystroke 68: } 69: export function parseChord(input: string): Chord { 70: if (input === ' ') return [parseKeystroke('space')] 71: return input.trim().split(/\s+/).map(parseKeystroke) 72: } 73: export function keystrokeToString(ks: ParsedKeystroke): string { 74: const parts: string[] = [] 75: if (ks.ctrl) parts.push('ctrl') 76: if (ks.alt) parts.push('alt') 77: if (ks.shift) parts.push('shift') 78: if (ks.meta) parts.push('meta') 79: if (ks.super) parts.push('cmd') 80: const displayKey = keyToDisplayName(ks.key) 81: parts.push(displayKey) 82: return parts.join('+') 83: } 84: function keyToDisplayName(key: string): string { 85: switch (key) { 86: case 'escape': 87: return 'Esc' 88: case ' ': 89: return 'Space' 90: case 'tab': 91: return 'tab' 92: case 'enter': 93: return 'Enter' 94: case 'backspace': 95: return 'Backspace' 96: case 'delete': 97: return 'Delete' 98: case 'up': 99: return '↑' 100: case 'down': 101: return '↓' 102: case 'left': 103: return '←' 104: case 'right': 105: return '→' 106: case 'pageup': 107: return 'PageUp' 108: case 'pagedown': 109: return 'PageDown' 110: case 'home': 111: return 'Home' 112: case 'end': 113: return 'End' 114: default: 115: return key 116: } 117: } 118: export function chordToString(chord: Chord): string { 119: return chord.map(keystrokeToString).join(' ') 120: } 121: type DisplayPlatform = 'macos' | 'windows' | 'linux' | 'wsl' | 'unknown' 122: export function keystrokeToDisplayString( 123: ks: ParsedKeystroke, 124: platform: DisplayPlatform = 'linux', 125: ): string { 126: const parts: string[] = [] 127: if (ks.ctrl) parts.push('ctrl') 128: if (ks.alt || ks.meta) { 129: parts.push(platform === 'macos' ? 'opt' : 'alt') 130: } 131: if (ks.shift) parts.push('shift') 132: if (ks.super) { 133: parts.push(platform === 'macos' ? 'cmd' : 'super') 134: } 135: const displayKey = keyToDisplayName(ks.key) 136: parts.push(displayKey) 137: return parts.join('+') 138: } 139: export function chordToDisplayString( 140: chord: Chord, 141: platform: DisplayPlatform = 'linux', 142: ): string { 143: return chord.map(ks => keystrokeToDisplayString(ks, platform)).join(' ') 144: } 145: export function parseBindings(blocks: KeybindingBlock[]): ParsedBinding[] { 146: const bindings: ParsedBinding[] = [] 147: for (const block of blocks) { 148: for (const [key, action] of Object.entries(block.bindings)) { 149: bindings.push({ 150: chord: parseChord(key), 151: action, 152: context: block.context, 153: }) 154: } 155: } 156: return bindings 157: }

File: src/keybindings/reservedShortcuts.ts

typescript 1: import { getPlatform } from '../utils/platform.js' 2: export type ReservedShortcut = { 3: key: string 4: reason: string 5: severity: 'error' | 'warning' 6: } 7: export const NON_REBINDABLE: ReservedShortcut[] = [ 8: { 9: key: 'ctrl+c', 10: reason: 'Cannot be rebound - used for interrupt/exit (hardcoded)', 11: severity: 'error', 12: }, 13: { 14: key: 'ctrl+d', 15: reason: 'Cannot be rebound - used for exit (hardcoded)', 16: severity: 'error', 17: }, 18: { 19: key: 'ctrl+m', 20: reason: 21: 'Cannot be rebound - identical to Enter in terminals (both send CR)', 22: severity: 'error', 23: }, 24: ] 25: export const TERMINAL_RESERVED: ReservedShortcut[] = [ 26: { 27: key: 'ctrl+z', 28: reason: 'Unix process suspend (SIGTSTP)', 29: severity: 'warning', 30: }, 31: { 32: key: 'ctrl+\\', 33: reason: 'Terminal quit signal (SIGQUIT)', 34: severity: 'error', 35: }, 36: ] 37: export const MACOS_RESERVED: ReservedShortcut[] = [ 38: { key: 'cmd+c', reason: 'macOS system copy', severity: 'error' }, 39: { key: 'cmd+v', reason: 'macOS system paste', severity: 'error' }, 40: { key: 'cmd+x', reason: 'macOS system cut', severity: 'error' }, 41: { key: 'cmd+q', reason: 'macOS quit application', severity: 'error' }, 42: { key: 'cmd+w', reason: 'macOS close window/tab', severity: 'error' }, 43: { key: 'cmd+tab', reason: 'macOS app switcher', severity: 'error' }, 44: { key: 'cmd+space', reason: 'macOS Spotlight', severity: 'error' }, 45: ] 46: export function getReservedShortcuts(): ReservedShortcut[] { 47: const platform = getPlatform() 48: const reserved = [...NON_REBINDABLE, ...TERMINAL_RESERVED] 49: if (platform === 'macos') { 50: reserved.push(...MACOS_RESERVED) 51: } 52: return reserved 53: } 54: export function normalizeKeyForComparison(key: string): string { 55: return key.trim().split(/\s+/).map(normalizeStep).join(' ') 56: } 57: function normalizeStep(step: string): string { 58: const parts = step.split('+') 59: const modifiers: string[] = [] 60: let mainKey = '' 61: for (const part of parts) { 62: const lower = part.trim().toLowerCase() 63: if ( 64: [ 65: 'ctrl', 66: 'control', 67: 'alt', 68: 'opt', 69: 'option', 70: 'meta', 71: 'cmd', 72: 'command', 73: 'shift', 74: ].includes(lower) 75: ) { 76: if (lower === 'control') modifiers.push('ctrl') 77: else if (lower === 'option' || lower === 'opt') modifiers.push('alt') 78: else if (lower === 'command' || lower === 'cmd') modifiers.push('cmd') 79: else modifiers.push(lower) 80: } else { 81: mainKey = lower 82: } 83: } 84: modifiers.sort() 85: return [...modifiers, mainKey].join('+') 86: }

File: src/keybindings/resolver.ts

typescript 1: import type { Key } from '../ink.js' 2: import { getKeyName, matchesBinding } from './match.js' 3: import { chordToString } from './parser.js' 4: import type { 5: KeybindingContextName, 6: ParsedBinding, 7: ParsedKeystroke, 8: } from './types.js' 9: export type ResolveResult = 10: | { type: 'match'; action: string } 11: | { type: 'none' } 12: | { type: 'unbound' } 13: export type ChordResolveResult = 14: | { type: 'match'; action: string } 15: | { type: 'none' } 16: | { type: 'unbound' } 17: | { type: 'chord_started'; pending: ParsedKeystroke[] } 18: | { type: 'chord_cancelled' } 19: export function resolveKey( 20: input: string, 21: key: Key, 22: activeContexts: KeybindingContextName[], 23: bindings: ParsedBinding[], 24: ): ResolveResult { 25: let match: ParsedBinding | undefined 26: const ctxSet = new Set(activeContexts) 27: for (const binding of bindings) { 28: if (binding.chord.length !== 1) continue 29: if (!ctxSet.has(binding.context)) continue 30: if (matchesBinding(input, key, binding)) { 31: match = binding 32: } 33: } 34: if (!match) { 35: return { type: 'none' } 36: } 37: if (match.action === null) { 38: return { type: 'unbound' } 39: } 40: return { type: 'match', action: match.action } 41: } 42: export function getBindingDisplayText( 43: action: string, 44: context: KeybindingContextName, 45: bindings: ParsedBinding[], 46: ): string | undefined { 47: const binding = bindings.findLast( 48: b => b.action === action && b.context === context, 49: ) 50: return binding ? chordToString(binding.chord) : undefined 51: } 52: function buildKeystroke(input: string, key: Key): ParsedKeystroke | null { 53: const keyName = getKeyName(input, key) 54: if (!keyName) return null 55: const effectiveMeta = key.escape ? false : key.meta 56: return { 57: key: keyName, 58: ctrl: key.ctrl, 59: alt: effectiveMeta, 60: shift: key.shift, 61: meta: effectiveMeta, 62: super: key.super, 63: } 64: } 65: export function keystrokesEqual( 66: a: ParsedKeystroke, 67: b: ParsedKeystroke, 68: ): boolean { 69: return ( 70: a.key === b.key && 71: a.ctrl === b.ctrl && 72: a.shift === b.shift && 73: (a.alt || a.meta) === (b.alt || b.meta) && 74: a.super === b.super 75: ) 76: } 77: function chordPrefixMatches( 78: prefix: ParsedKeystroke[], 79: binding: ParsedBinding, 80: ): boolean { 81: if (prefix.length >= binding.chord.length) return false 82: for (let i = 0; i < prefix.length; i++) { 83: const prefixKey = prefix[i] 84: const bindingKey = binding.chord[i] 85: if (!prefixKey || !bindingKey) return false 86: if (!keystrokesEqual(prefixKey, bindingKey)) return false 87: } 88: return true 89: } 90: function chordExactlyMatches( 91: chord: ParsedKeystroke[], 92: binding: ParsedBinding, 93: ): boolean { 94: if (chord.length !== binding.chord.length) return false 95: for (let i = 0; i < chord.length; i++) { 96: const chordKey = chord[i] 97: const bindingKey = binding.chord[i] 98: if (!chordKey || !bindingKey) return false 99: if (!keystrokesEqual(chordKey, bindingKey)) return false 100: } 101: return true 102: } 103: export function resolveKeyWithChordState( 104: input: string, 105: key: Key, 106: activeContexts: KeybindingContextName[], 107: bindings: ParsedBinding[], 108: pending: ParsedKeystroke[] | null, 109: ): ChordResolveResult { 110: if (key.escape && pending !== null) { 111: return { type: 'chord_cancelled' } 112: } 113: const currentKeystroke = buildKeystroke(input, key) 114: if (!currentKeystroke) { 115: if (pending !== null) { 116: return { type: 'chord_cancelled' } 117: } 118: return { type: 'none' } 119: } 120: const testChord = pending 121: ? [...pending, currentKeystroke] 122: : [currentKeystroke] 123: const ctxSet = new Set(activeContexts) 124: const contextBindings = bindings.filter(b => ctxSet.has(b.context)) 125: const chordWinners = new Map<string, string | null>() 126: for (const binding of contextBindings) { 127: if ( 128: binding.chord.length > testChord.length && 129: chordPrefixMatches(testChord, binding) 130: ) { 131: chordWinners.set(chordToString(binding.chord), binding.action) 132: } 133: } 134: let hasLongerChords = false 135: for (const action of chordWinners.values()) { 136: if (action !== null) { 137: hasLongerChords = true 138: break 139: } 140: } 141: if (hasLongerChords) { 142: return { type: 'chord_started', pending: testChord } 143: } 144: let exactMatch: ParsedBinding | undefined 145: for (const binding of contextBindings) { 146: if (chordExactlyMatches(testChord, binding)) { 147: exactMatch = binding 148: } 149: } 150: if (exactMatch) { 151: if (exactMatch.action === null) { 152: return { type: 'unbound' } 153: } 154: return { type: 'match', action: exactMatch.action } 155: } 156: if (pending !== null) { 157: return { type: 'chord_cancelled' } 158: } 159: return { type: 'none' } 160: }

File: src/keybindings/schema.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../utils/lazySchema.js' 3: export const KEYBINDING_CONTEXTS = [ 4: 'Global', 5: 'Chat', 6: 'Autocomplete', 7: 'Confirmation', 8: 'Help', 9: 'Transcript', 10: 'HistorySearch', 11: 'Task', 12: 'ThemePicker', 13: 'Settings', 14: 'Tabs', 15: 'Attachments', 16: 'Footer', 17: 'MessageSelector', 18: 'DiffDialog', 19: 'ModelPicker', 20: 'Select', 21: 'Plugin', 22: ] as const 23: export const KEYBINDING_CONTEXT_DESCRIPTIONS: Record< 24: (typeof KEYBINDING_CONTEXTS)[number], 25: string 26: > = { 27: Global: 'Active everywhere, regardless of focus', 28: Chat: 'When the chat input is focused', 29: Autocomplete: 'When autocomplete menu is visible', 30: Confirmation: 'When a confirmation/permission dialog is shown', 31: Help: 'When the help overlay is open', 32: Transcript: 'When viewing the transcript', 33: HistorySearch: 'When searching command history (ctrl+r)', 34: Task: 'When a task/agent is running in the foreground', 35: ThemePicker: 'When the theme picker is open', 36: Settings: 'When the settings menu is open', 37: Tabs: 'When tab navigation is active', 38: Attachments: 'When navigating image attachments in a select dialog', 39: Footer: 'When footer indicators are focused', 40: MessageSelector: 'When the message selector (rewind) is open', 41: DiffDialog: 'When the diff dialog is open', 42: ModelPicker: 'When the model picker is open', 43: Select: 'When a select/list component is focused', 44: Plugin: 'When the plugin dialog is open', 45: } 46: export const KEYBINDING_ACTIONS = [ 47: 'app:interrupt', 48: 'app:exit', 49: 'app:toggleTodos', 50: 'app:toggleTranscript', 51: 'app:toggleBrief', 52: 'app:toggleTeammatePreview', 53: 'app:toggleTerminal', 54: 'app:redraw', 55: 'app:globalSearch', 56: 'app:quickOpen', 57: 'history:search', 58: 'history:previous', 59: 'history:next', 60: 'chat:cancel', 61: 'chat:killAgents', 62: 'chat:cycleMode', 63: 'chat:modelPicker', 64: 'chat:fastMode', 65: 'chat:thinkingToggle', 66: 'chat:submit', 67: 'chat:newline', 68: 'chat:undo', 69: 'chat:externalEditor', 70: 'chat:stash', 71: 'chat:imagePaste', 72: 'chat:messageActions', 73: 'autocomplete:accept', 74: 'autocomplete:dismiss', 75: 'autocomplete:previous', 76: 'autocomplete:next', 77: 'confirm:yes', 78: 'confirm:no', 79: 'confirm:previous', 80: 'confirm:next', 81: 'confirm:nextField', 82: 'confirm:previousField', 83: 'confirm:cycleMode', 84: 'confirm:toggle', 85: 'confirm:toggleExplanation', 86: 'tabs:next', 87: 'tabs:previous', 88: 'transcript:toggleShowAll', 89: 'transcript:exit', 90: 'historySearch:next', 91: 'historySearch:accept', 92: 'historySearch:cancel', 93: 'historySearch:execute', 94: 'task:background', 95: 'theme:toggleSyntaxHighlighting', 96: 'help:dismiss', 97: 'attachments:next', 98: 'attachments:previous', 99: 'attachments:remove', 100: 'attachments:exit', 101: 'footer:up', 102: 'footer:down', 103: 'footer:next', 104: 'footer:previous', 105: 'footer:openSelected', 106: 'footer:clearSelection', 107: 'footer:close', 108: 'messageSelector:up', 109: 'messageSelector:down', 110: 'messageSelector:top', 111: 'messageSelector:bottom', 112: 'messageSelector:select', 113: 'diff:dismiss', 114: 'diff:previousSource', 115: 'diff:nextSource', 116: 'diff:back', 117: 'diff:viewDetails', 118: 'diff:previousFile', 119: 'diff:nextFile', 120: 'modelPicker:decreaseEffort', 121: 'modelPicker:increaseEffort', 122: 'select:next', 123: 'select:previous', 124: 'select:accept', 125: 'select:cancel', 126: 'plugin:toggle', 127: 'plugin:install', 128: 'permission:toggleDebug', 129: 'settings:search', 130: 'settings:retry', 131: 'settings:close', 132: 'voice:pushToTalk', 133: ] as const 134: export const KeybindingBlockSchema = lazySchema(() => 135: z 136: .object({ 137: context: z 138: .enum(KEYBINDING_CONTEXTS) 139: .describe( 140: 'UI context where these bindings apply. Global bindings work everywhere.', 141: ), 142: bindings: z 143: .record( 144: z 145: .string() 146: .describe('Keystroke pattern (e.g., "ctrl+k", "shift+tab")'), 147: z 148: .union([ 149: z.enum(KEYBINDING_ACTIONS), 150: z 151: .string() 152: .regex(/^command:[a-zA-Z0-9:\-_]+$/) 153: .describe( 154: 'Command binding (e.g., "command:help", "command:compact"). Executes the slash command as if typed.', 155: ), 156: z.null().describe('Set to null to unbind a default shortcut'), 157: ]) 158: .describe( 159: 'Action to trigger, command to invoke, or null to unbind', 160: ), 161: ) 162: .describe('Map of keystroke patterns to actions'), 163: }) 164: .describe('A block of keybindings for a specific context'), 165: ) 166: export const KeybindingsSchema = lazySchema(() => 167: z 168: .object({ 169: $schema: z 170: .string() 171: .optional() 172: .describe('JSON Schema URL for editor validation'), 173: $docs: z.string().optional().describe('Documentation URL'), 174: bindings: z 175: .array(KeybindingBlockSchema()) 176: .describe('Array of keybinding blocks by context'), 177: }) 178: .describe( 179: 'Claude Code keybindings configuration. Customize keyboard shortcuts by context.', 180: ), 181: ) 182: export type KeybindingsSchemaType = z.infer< 183: ReturnType<typeof KeybindingsSchema> 184: >

File: src/keybindings/shortcutFormat.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from '../services/analytics/index.js' 5: import { loadKeybindingsSync } from './loadUserBindings.js' 6: import { getBindingDisplayText } from './resolver.js' 7: import type { KeybindingContextName } from './types.js' 8: const LOGGED_FALLBACKS = new Set<string>() 9: export function getShortcutDisplay( 10: action: string, 11: context: KeybindingContextName, 12: fallback: string, 13: ): string { 14: const bindings = loadKeybindingsSync() 15: const resolved = getBindingDisplayText(action, context, bindings) 16: if (resolved === undefined) { 17: const key = `${action}:${context}` 18: if (!LOGGED_FALLBACKS.has(key)) { 19: LOGGED_FALLBACKS.add(key) 20: logEvent('tengu_keybinding_fallback_used', { 21: action: 22: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 23: context: 24: context as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 25: fallback: 26: fallback as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 27: reason: 28: 'action_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 29: }) 30: } 31: return fallback 32: } 33: return resolved 34: }

File: src/keybindings/template.ts

typescript 1: import { jsonStringify } from '../utils/slowOperations.js' 2: import { DEFAULT_BINDINGS } from './defaultBindings.js' 3: import { 4: NON_REBINDABLE, 5: normalizeKeyForComparison, 6: } from './reservedShortcuts.js' 7: import type { KeybindingBlock } from './types.js' 8: function filterReservedShortcuts(blocks: KeybindingBlock[]): KeybindingBlock[] { 9: const reservedKeys = new Set( 10: NON_REBINDABLE.map(r => normalizeKeyForComparison(r.key)), 11: ) 12: return blocks 13: .map(block => { 14: const filteredBindings: Record<string, string | null> = {} 15: for (const [key, action] of Object.entries(block.bindings)) { 16: if (!reservedKeys.has(normalizeKeyForComparison(key))) { 17: filteredBindings[key] = action 18: } 19: } 20: return { context: block.context, bindings: filteredBindings } 21: }) 22: .filter(block => Object.keys(block.bindings).length > 0) 23: } 24: export function generateKeybindingsTemplate(): string { 25: const bindings = filterReservedShortcuts(DEFAULT_BINDINGS) 26: const config = { 27: $schema: 'https://www.schemastore.org/claude-code-keybindings.json', 28: $docs: 'https://code.claude.com/docs/en/keybindings', 29: bindings, 30: } 31: return jsonStringify(config, null, 2) + '\n' 32: }

File: src/keybindings/useKeybinding.ts

typescript 1: import { useCallback, useEffect } from 'react' 2: import type { InputEvent } from '../ink/events/input-event.js' 3: import { type Key, useInput } from '../ink.js' 4: import { useOptionalKeybindingContext } from './KeybindingContext.js' 5: import type { KeybindingContextName } from './types.js' 6: type Options = { 7: context?: KeybindingContextName 8: isActive?: boolean 9: } 10: export function useKeybinding( 11: action: string, 12: handler: () => void | false | Promise<void>, 13: options: Options = {}, 14: ): void { 15: const { context = 'Global', isActive = true } = options 16: const keybindingContext = useOptionalKeybindingContext() 17: useEffect(() => { 18: if (!keybindingContext || !isActive) return 19: return keybindingContext.registerHandler({ action, context, handler }) 20: }, [action, context, handler, keybindingContext, isActive]) 21: const handleInput = useCallback( 22: (input: string, key: Key, event: InputEvent) => { 23: if (!keybindingContext) return 24: const contextsToCheck: KeybindingContextName[] = [ 25: ...keybindingContext.activeContexts, 26: context, 27: 'Global', 28: ] 29: const uniqueContexts = [...new Set(contextsToCheck)] 30: const result = keybindingContext.resolve(input, key, uniqueContexts) 31: switch (result.type) { 32: case 'match': 33: keybindingContext.setPendingChord(null) 34: if (result.action === action) { 35: if (handler() !== false) { 36: event.stopImmediatePropagation() 37: } 38: } 39: break 40: case 'chord_started': 41: keybindingContext.setPendingChord(result.pending) 42: event.stopImmediatePropagation() 43: break 44: case 'chord_cancelled': 45: keybindingContext.setPendingChord(null) 46: break 47: case 'unbound': 48: keybindingContext.setPendingChord(null) 49: event.stopImmediatePropagation() 50: break 51: case 'none': 52: break 53: } 54: }, 55: [action, context, handler, keybindingContext], 56: ) 57: useInput(handleInput, { isActive }) 58: } 59: export function useKeybindings( 60: handlers: Record<string, () => void | false | Promise<void>>, 61: options: Options = {}, 62: ): void { 63: const { context = 'Global', isActive = true } = options 64: const keybindingContext = useOptionalKeybindingContext() 65: useEffect(() => { 66: if (!keybindingContext || !isActive) return 67: const unregisterFns: Array<() => void> = [] 68: for (const [action, handler] of Object.entries(handlers)) { 69: unregisterFns.push( 70: keybindingContext.registerHandler({ action, context, handler }), 71: ) 72: } 73: return () => { 74: for (const unregister of unregisterFns) { 75: unregister() 76: } 77: } 78: }, [context, handlers, keybindingContext, isActive]) 79: const handleInput = useCallback( 80: (input: string, key: Key, event: InputEvent) => { 81: if (!keybindingContext) return 82: const contextsToCheck: KeybindingContextName[] = [ 83: ...keybindingContext.activeContexts, 84: context, 85: 'Global', 86: ] 87: const uniqueContexts = [...new Set(contextsToCheck)] 88: const result = keybindingContext.resolve(input, key, uniqueContexts) 89: switch (result.type) { 90: case 'match': 91: keybindingContext.setPendingChord(null) 92: if (result.action in handlers) { 93: const handler = handlers[result.action] 94: if (handler && handler() !== false) { 95: event.stopImmediatePropagation() 96: } 97: } 98: break 99: case 'chord_started': 100: keybindingContext.setPendingChord(result.pending) 101: event.stopImmediatePropagation() 102: break 103: case 'chord_cancelled': 104: keybindingContext.setPendingChord(null) 105: break 106: case 'unbound': 107: keybindingContext.setPendingChord(null) 108: event.stopImmediatePropagation() 109: break 110: case 'none': 111: break 112: } 113: }, 114: [context, handlers, keybindingContext], 115: ) 116: useInput(handleInput, { isActive }) 117: }

File: src/keybindings/useShortcutDisplay.ts

typescript 1: import { useEffect, useRef } from 'react' 2: import { 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: logEvent, 5: } from '../services/analytics/index.js' 6: import { useOptionalKeybindingContext } from './KeybindingContext.js' 7: import type { KeybindingContextName } from './types.js' 8: export function useShortcutDisplay( 9: action: string, 10: context: KeybindingContextName, 11: fallback: string, 12: ): string { 13: const keybindingContext = useOptionalKeybindingContext() 14: const resolved = keybindingContext?.getDisplayText(action, context) 15: const isFallback = resolved === undefined 16: const reason = keybindingContext ? 'action_not_found' : 'no_context' 17: const hasLoggedRef = useRef(false) 18: useEffect(() => { 19: if (isFallback && !hasLoggedRef.current) { 20: hasLoggedRef.current = true 21: logEvent('tengu_keybinding_fallback_used', { 22: action: 23: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 24: context: 25: context as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 26: fallback: 27: fallback as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 28: reason: 29: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 30: }) 31: } 32: }, [isFallback, action, context, fallback, reason]) 33: return isFallback ? fallback : resolved 34: }

File: src/keybindings/validate.ts

typescript 1: import { plural } from '../utils/stringUtils.js' 2: import { chordToString, parseChord, parseKeystroke } from './parser.js' 3: import { 4: getReservedShortcuts, 5: normalizeKeyForComparison, 6: } from './reservedShortcuts.js' 7: import type { 8: KeybindingBlock, 9: KeybindingContextName, 10: ParsedBinding, 11: } from './types.js' 12: export type KeybindingWarningType = 13: | 'parse_error' 14: | 'duplicate' 15: | 'reserved' 16: | 'invalid_context' 17: | 'invalid_action' 18: export type KeybindingWarning = { 19: type: KeybindingWarningType 20: severity: 'error' | 'warning' 21: message: string 22: key?: string 23: context?: string 24: action?: string 25: suggestion?: string 26: } 27: function isKeybindingBlock(obj: unknown): obj is KeybindingBlock { 28: if (typeof obj !== 'object' || obj === null) return false 29: const b = obj as Record<string, unknown> 30: return ( 31: typeof b.context === 'string' && 32: typeof b.bindings === 'object' && 33: b.bindings !== null 34: ) 35: } 36: function isKeybindingBlockArray(arr: unknown): arr is KeybindingBlock[] { 37: return Array.isArray(arr) && arr.every(isKeybindingBlock) 38: } 39: const VALID_CONTEXTS: KeybindingContextName[] = [ 40: 'Global', 41: 'Chat', 42: 'Autocomplete', 43: 'Confirmation', 44: 'Help', 45: 'Transcript', 46: 'HistorySearch', 47: 'Task', 48: 'ThemePicker', 49: 'Settings', 50: 'Tabs', 51: 'Attachments', 52: 'Footer', 53: 'MessageSelector', 54: 'DiffDialog', 55: 'ModelPicker', 56: 'Select', 57: 'Plugin', 58: ] 59: function isValidContext(value: string): value is KeybindingContextName { 60: return (VALID_CONTEXTS as readonly string[]).includes(value) 61: } 62: function validateKeystroke(keystroke: string): KeybindingWarning | null { 63: const parts = keystroke.toLowerCase().split('+') 64: for (const part of parts) { 65: const trimmed = part.trim() 66: if (!trimmed) { 67: return { 68: type: 'parse_error', 69: severity: 'error', 70: message: `Empty key part in "${keystroke}"`, 71: key: keystroke, 72: suggestion: 'Remove extra "+" characters', 73: } 74: } 75: } 76: const parsed = parseKeystroke(keystroke) 77: if ( 78: !parsed.key && 79: !parsed.ctrl && 80: !parsed.alt && 81: !parsed.shift && 82: !parsed.meta 83: ) { 84: return { 85: type: 'parse_error', 86: severity: 'error', 87: message: `Could not parse keystroke "${keystroke}"`, 88: key: keystroke, 89: } 90: } 91: return null 92: } 93: function validateBlock( 94: block: unknown, 95: blockIndex: number, 96: ): KeybindingWarning[] { 97: const warnings: KeybindingWarning[] = [] 98: if (typeof block !== 'object' || block === null) { 99: warnings.push({ 100: type: 'parse_error', 101: severity: 'error', 102: message: `Keybinding block ${blockIndex + 1} is not an object`, 103: }) 104: return warnings 105: } 106: const b = block as Record<string, unknown> 107: const rawContext = b.context 108: let contextName: string | undefined 109: if (typeof rawContext !== 'string') { 110: warnings.push({ 111: type: 'parse_error', 112: severity: 'error', 113: message: `Keybinding block ${blockIndex + 1} missing "context" field`, 114: }) 115: } else if (!isValidContext(rawContext)) { 116: warnings.push({ 117: type: 'invalid_context', 118: severity: 'error', 119: message: `Unknown context "${rawContext}"`, 120: context: rawContext, 121: suggestion: `Valid contexts: ${VALID_CONTEXTS.join(', ')}`, 122: }) 123: } else { 124: contextName = rawContext 125: } 126: if (typeof b.bindings !== 'object' || b.bindings === null) { 127: warnings.push({ 128: type: 'parse_error', 129: severity: 'error', 130: message: `Keybinding block ${blockIndex + 1} missing "bindings" field`, 131: }) 132: return warnings 133: } 134: const bindings = b.bindings as Record<string, unknown> 135: for (const [key, action] of Object.entries(bindings)) { 136: const keyError = validateKeystroke(key) 137: if (keyError) { 138: keyError.context = contextName 139: warnings.push(keyError) 140: } 141: if (action !== null && typeof action !== 'string') { 142: warnings.push({ 143: type: 'invalid_action', 144: severity: 'error', 145: message: `Invalid action for "${key}": must be a string or null`, 146: key, 147: context: contextName, 148: }) 149: } else if (typeof action === 'string' && action.startsWith('command:')) { 150: if (!/^command:[a-zA-Z0-9:\-_]+$/.test(action)) { 151: warnings.push({ 152: type: 'invalid_action', 153: severity: 'warning', 154: message: `Invalid command binding "${action}" for "${key}": command name may only contain alphanumeric characters, colons, hyphens, and underscores`, 155: key, 156: context: contextName, 157: action, 158: }) 159: } 160: if (contextName && contextName !== 'Chat') { 161: warnings.push({ 162: type: 'invalid_action', 163: severity: 'warning', 164: message: `Command binding "${action}" must be in "Chat" context, not "${contextName}"`, 165: key, 166: context: contextName, 167: action, 168: suggestion: 'Move this binding to a block with "context": "Chat"', 169: }) 170: } 171: } else if (action === 'voice:pushToTalk') { 172: const ks = parseChord(key)[0] 173: if ( 174: ks && 175: !ks.ctrl && 176: !ks.alt && 177: !ks.shift && 178: !ks.meta && 179: !ks.super && 180: /^[a-z]$/.test(ks.key) 181: ) { 182: warnings.push({ 183: type: 'invalid_action', 184: severity: 'warning', 185: message: `Binding "${key}" to voice:pushToTalk prints into the input during warmup; use space or a modifier combo like meta+k`, 186: key, 187: context: contextName, 188: action, 189: }) 190: } 191: } 192: } 193: return warnings 194: } 195: export function checkDuplicateKeysInJson( 196: jsonString: string, 197: ): KeybindingWarning[] { 198: const warnings: KeybindingWarning[] = [] 199: const bindingsBlockPattern = 200: /"bindings"\s*:\s*\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g 201: let blockMatch 202: while ((blockMatch = bindingsBlockPattern.exec(jsonString)) !== null) { 203: const blockContent = blockMatch[1] 204: if (!blockContent) continue 205: const textBeforeBlock = jsonString.slice(0, blockMatch.index) 206: const contextMatch = textBeforeBlock.match( 207: /"context"\s*:\s*"([^"]+)"[^{]*$/, 208: ) 209: const context = contextMatch?.[1] ?? 'unknown' 210: // Find all keys within this bindings block 211: const keyPattern = /"([^"]+)"\s*:/g 212: const keysByName = new Map<string, number>() 213: let keyMatch 214: while ((keyMatch = keyPattern.exec(blockContent)) !== null) { 215: const key = keyMatch[1] 216: if (!key) continue 217: const count = (keysByName.get(key) ?? 0) + 1 218: keysByName.set(key, count) 219: if (count === 2) { 220: warnings.push({ 221: type: 'duplicate', 222: severity: 'warning', 223: message: `Duplicate key "${key}" in ${context} bindings`, 224: key, 225: context, 226: suggestion: `This key appears multiple times in the same context. JSON uses the last value, earlier values are ignored.`, 227: }) 228: } 229: } 230: } 231: return warnings 232: } 233: export function validateUserConfig(userBlocks: unknown): KeybindingWarning[] { 234: const warnings: KeybindingWarning[] = [] 235: if (!Array.isArray(userBlocks)) { 236: warnings.push({ 237: type: 'parse_error', 238: severity: 'error', 239: message: 'keybindings.json must contain an array', 240: suggestion: 'Wrap your bindings in [ ]', 241: }) 242: return warnings 243: } 244: for (let i = 0; i < userBlocks.length; i++) { 245: warnings.push(...validateBlock(userBlocks[i], i)) 246: } 247: return warnings 248: } 249: export function checkDuplicates( 250: blocks: KeybindingBlock[], 251: ): KeybindingWarning[] { 252: const warnings: KeybindingWarning[] = [] 253: const seenByContext = new Map<string, Map<string, string>>() 254: for (const block of blocks) { 255: const contextMap = 256: seenByContext.get(block.context) ?? new Map<string, string>() 257: seenByContext.set(block.context, contextMap) 258: for (const [key, action] of Object.entries(block.bindings)) { 259: const normalizedKey = normalizeKeyForComparison(key) 260: const existingAction = contextMap.get(normalizedKey) 261: if (existingAction && existingAction !== action) { 262: warnings.push({ 263: type: 'duplicate', 264: severity: 'warning', 265: message: `Duplicate binding "${key}" in ${block.context} context`, 266: key, 267: context: block.context, 268: action: action ?? 'null (unbind)', 269: suggestion: `Previously bound to "${existingAction}". Only the last binding will be used.`, 270: }) 271: } 272: contextMap.set(normalizedKey, action ?? 'null') 273: } 274: } 275: return warnings 276: } 277: export function checkReservedShortcuts( 278: bindings: ParsedBinding[], 279: ): KeybindingWarning[] { 280: const warnings: KeybindingWarning[] = [] 281: const reserved = getReservedShortcuts() 282: for (const binding of bindings) { 283: const keyDisplay = chordToString(binding.chord) 284: const normalizedKey = normalizeKeyForComparison(keyDisplay) 285: for (const res of reserved) { 286: if (normalizeKeyForComparison(res.key) === normalizedKey) { 287: warnings.push({ 288: type: 'reserved', 289: severity: res.severity, 290: message: `"${keyDisplay}" may not work: ${res.reason}`, 291: key: keyDisplay, 292: context: binding.context, 293: action: binding.action ?? undefined, 294: }) 295: } 296: } 297: } 298: return warnings 299: } 300: function getUserBindingsForValidation( 301: userBlocks: KeybindingBlock[], 302: ): ParsedBinding[] { 303: const bindings: ParsedBinding[] = [] 304: for (const block of userBlocks) { 305: for (const [key, action] of Object.entries(block.bindings)) { 306: const chord = key.split(' ').map(k => parseKeystroke(k)) 307: bindings.push({ 308: chord, 309: action, 310: context: block.context, 311: }) 312: } 313: } 314: return bindings 315: } 316: export function validateBindings( 317: userBlocks: unknown, 318: _parsedBindings: ParsedBinding[], 319: ): KeybindingWarning[] { 320: const warnings: KeybindingWarning[] = [] 321: warnings.push(...validateUserConfig(userBlocks)) 322: if (isKeybindingBlockArray(userBlocks)) { 323: warnings.push(...checkDuplicates(userBlocks)) 324: const userBindings = getUserBindingsForValidation(userBlocks) 325: warnings.push(...checkReservedShortcuts(userBindings)) 326: } 327: const seen = new Set<string>() 328: return warnings.filter(w => { 329: const key = `${w.type}:${w.key}:${w.context}` 330: if (seen.has(key)) return false 331: seen.add(key) 332: return true 333: }) 334: } 335: export function formatWarning(warning: KeybindingWarning): string { 336: const icon = warning.severity === 'error' ? '✗' : '⚠' 337: let msg = `${icon} Keybinding ${warning.severity}: ${warning.message}` 338: if (warning.suggestion) { 339: msg += `\n ${warning.suggestion}` 340: } 341: return msg 342: } 343: export function formatWarnings(warnings: KeybindingWarning[]): string { 344: if (warnings.length === 0) return '' 345: const errors = warnings.filter(w => w.severity === 'error') 346: const warns = warnings.filter(w => w.severity === 'warning') 347: const lines: string[] = [] 348: if (errors.length > 0) { 349: lines.push( 350: `Found ${errors.length} keybinding ${plural(errors.length, 'error')}:`, 351: ) 352: for (const e of errors) { 353: lines.push(formatWarning(e)) 354: } 355: } 356: if (warns.length > 0) { 357: if (lines.length > 0) lines.push('') 358: lines.push( 359: `Found ${warns.length} keybinding ${plural(warns.length, 'warning')}:`, 360: ) 361: for (const w of warns) { 362: lines.push(formatWarning(w)) 363: } 364: } 365: return lines.join('\n') 366: }

File: src/memdir/findRelevantMemories.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { logForDebugging } from '../utils/debug.js' 3: import { errorMessage } from '../utils/errors.js' 4: import { getDefaultSonnetModel } from '../utils/model/model.js' 5: import { sideQuery } from '../utils/sideQuery.js' 6: import { jsonParse } from '../utils/slowOperations.js' 7: import { 8: formatMemoryManifest, 9: type MemoryHeader, 10: scanMemoryFiles, 11: } from './memoryScan.js' 12: export type RelevantMemory = { 13: path: string 14: mtimeMs: number 15: } 16: const SELECT_MEMORIES_SYSTEM_PROMPT = `You are selecting memories that will be useful to Claude Code as it processes a user's query. You will be given the user's query and a list of available memory files with their filenames and descriptions. 17: Return a list of filenames for the memories that will clearly be useful to Claude Code as it processes the user's query (up to 5). Only include memories that you are certain will be helpful based on their name and description. 18: - If you are unsure if a memory will be useful in processing the user's query, then do not include it in your list. Be selective and discerning. 19: - If there are no memories in the list that would clearly be useful, feel free to return an empty list. 20: - If a list of recently-used tools is provided, do not select memories that are usage reference or API documentation for those tools (Claude Code is already exercising them). DO still select memories containing warnings, gotchas, or known issues about those tools — active use is exactly when those matter. 21: ` 22: export async function findRelevantMemories( 23: query: string, 24: memoryDir: string, 25: signal: AbortSignal, 26: recentTools: readonly string[] = [], 27: alreadySurfaced: ReadonlySet<string> = new Set(), 28: ): Promise<RelevantMemory[]> { 29: const memories = (await scanMemoryFiles(memoryDir, signal)).filter( 30: m => !alreadySurfaced.has(m.filePath), 31: ) 32: if (memories.length === 0) { 33: return [] 34: } 35: const selectedFilenames = await selectRelevantMemories( 36: query, 37: memories, 38: signal, 39: recentTools, 40: ) 41: const byFilename = new Map(memories.map(m => [m.filename, m])) 42: const selected = selectedFilenames 43: .map(filename => byFilename.get(filename)) 44: .filter((m): m is MemoryHeader => m !== undefined) 45: if (feature('MEMORY_SHAPE_TELEMETRY')) { 46: const { logMemoryRecallShape } = 47: require('./memoryShapeTelemetry.js') as typeof import('./memoryShapeTelemetry.js') 48: logMemoryRecallShape(memories, selected) 49: } 50: return selected.map(m => ({ path: m.filePath, mtimeMs: m.mtimeMs })) 51: } 52: async function selectRelevantMemories( 53: query: string, 54: memories: MemoryHeader[], 55: signal: AbortSignal, 56: recentTools: readonly string[], 57: ): Promise<string[]> { 58: const validFilenames = new Set(memories.map(m => m.filename)) 59: const manifest = formatMemoryManifest(memories) 60: const toolsSection = 61: recentTools.length > 0 62: ? `\n\nRecently used tools: ${recentTools.join(', ')}` 63: : '' 64: try { 65: const result = await sideQuery({ 66: model: getDefaultSonnetModel(), 67: system: SELECT_MEMORIES_SYSTEM_PROMPT, 68: skipSystemPromptPrefix: true, 69: messages: [ 70: { 71: role: 'user', 72: content: `Query: ${query}\n\nAvailable memories:\n${manifest}${toolsSection}`, 73: }, 74: ], 75: max_tokens: 256, 76: output_format: { 77: type: 'json_schema', 78: schema: { 79: type: 'object', 80: properties: { 81: selected_memories: { type: 'array', items: { type: 'string' } }, 82: }, 83: required: ['selected_memories'], 84: additionalProperties: false, 85: }, 86: }, 87: signal, 88: querySource: 'memdir_relevance', 89: }) 90: const textBlock = result.content.find(block => block.type === 'text') 91: if (!textBlock || textBlock.type !== 'text') { 92: return [] 93: } 94: const parsed: { selected_memories: string[] } = jsonParse(textBlock.text) 95: return parsed.selected_memories.filter(f => validFilenames.has(f)) 96: } catch (e) { 97: if (signal.aborted) { 98: return [] 99: } 100: logForDebugging( 101: `[memdir] selectRelevantMemories failed: ${errorMessage(e)}`, 102: { level: 'warn' }, 103: ) 104: return [] 105: } 106: }

File: src/memdir/memdir.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { join } from 'path' 3: import { getFsImplementation } from '../utils/fsOperations.js' 4: import { getAutoMemPath, isAutoMemoryEnabled } from './paths.js' 5: const teamMemPaths = feature('TEAMMEM') 6: ? (require('./teamMemPaths.js') as typeof import('./teamMemPaths.js')) 7: : null 8: import { getKairosActive, getOriginalCwd } from '../bootstrap/state.js' 9: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 10: import { 11: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 12: logEvent, 13: } from '../services/analytics/index.js' 14: import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js' 15: import { isReplModeEnabled } from '../tools/REPLTool/constants.js' 16: import { logForDebugging } from '../utils/debug.js' 17: import { hasEmbeddedSearchTools } from '../utils/embeddedTools.js' 18: import { isEnvTruthy } from '../utils/envUtils.js' 19: import { formatFileSize } from '../utils/format.js' 20: import { getProjectDir } from '../utils/sessionStorage.js' 21: import { getInitialSettings } from '../utils/settings/settings.js' 22: import { 23: MEMORY_FRONTMATTER_EXAMPLE, 24: TRUSTING_RECALL_SECTION, 25: TYPES_SECTION_INDIVIDUAL, 26: WHAT_NOT_TO_SAVE_SECTION, 27: WHEN_TO_ACCESS_SECTION, 28: } from './memoryTypes.js' 29: export const ENTRYPOINT_NAME = 'MEMORY.md' 30: export const MAX_ENTRYPOINT_LINES = 200 31: export const MAX_ENTRYPOINT_BYTES = 25_000 32: const AUTO_MEM_DISPLAY_NAME = 'auto memory' 33: export type EntrypointTruncation = { 34: content: string 35: lineCount: number 36: byteCount: number 37: wasLineTruncated: boolean 38: wasByteTruncated: boolean 39: } 40: export function truncateEntrypointContent(raw: string): EntrypointTruncation { 41: const trimmed = raw.trim() 42: const contentLines = trimmed.split('\n') 43: const lineCount = contentLines.length 44: const byteCount = trimmed.length 45: const wasLineTruncated = lineCount > MAX_ENTRYPOINT_LINES 46: const wasByteTruncated = byteCount > MAX_ENTRYPOINT_BYTES 47: if (!wasLineTruncated && !wasByteTruncated) { 48: return { 49: content: trimmed, 50: lineCount, 51: byteCount, 52: wasLineTruncated, 53: wasByteTruncated, 54: } 55: } 56: let truncated = wasLineTruncated 57: ? contentLines.slice(0, MAX_ENTRYPOINT_LINES).join('\n') 58: : trimmed 59: if (truncated.length > MAX_ENTRYPOINT_BYTES) { 60: const cutAt = truncated.lastIndexOf('\n', MAX_ENTRYPOINT_BYTES) 61: truncated = truncated.slice(0, cutAt > 0 ? cutAt : MAX_ENTRYPOINT_BYTES) 62: } 63: const reason = 64: wasByteTruncated && !wasLineTruncated 65: ? `${formatFileSize(byteCount)} (limit: ${formatFileSize(MAX_ENTRYPOINT_BYTES)}) — index entries are too long` 66: : wasLineTruncated && !wasByteTruncated 67: ? `${lineCount} lines (limit: ${MAX_ENTRYPOINT_LINES})` 68: : `${lineCount} lines and ${formatFileSize(byteCount)}` 69: return { 70: content: 71: truncated + 72: `\n\n> WARNING: ${ENTRYPOINT_NAME} is ${reason}. Only part of it was loaded. Keep index entries to one line under ~200 chars; move detail into topic files.`, 73: lineCount, 74: byteCount, 75: wasLineTruncated, 76: wasByteTruncated, 77: } 78: } 79: const teamMemPrompts = feature('TEAMMEM') 80: ? (require('./teamMemPrompts.js') as typeof import('./teamMemPrompts.js')) 81: : null 82: export const DIR_EXISTS_GUIDANCE = 83: 'This directory already exists — write to it directly with the Write tool (do not run mkdir or check for its existence).' 84: export const DIRS_EXIST_GUIDANCE = 85: 'Both directories already exist — write to them directly with the Write tool (do not run mkdir or check for their existence).' 86: export async function ensureMemoryDirExists(memoryDir: string): Promise<void> { 87: const fs = getFsImplementation() 88: try { 89: await fs.mkdir(memoryDir) 90: } catch (e) { 91: const code = 92: e instanceof Error && 'code' in e && typeof e.code === 'string' 93: ? e.code 94: : undefined 95: logForDebugging( 96: `ensureMemoryDirExists failed for ${memoryDir}: ${code ?? String(e)}`, 97: { level: 'debug' }, 98: ) 99: } 100: } 101: function logMemoryDirCounts( 102: memoryDir: string, 103: baseMetadata: Record< 104: string, 105: | number 106: | boolean 107: | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 108: >, 109: ): void { 110: const fs = getFsImplementation() 111: void fs.readdir(memoryDir).then( 112: dirents => { 113: let fileCount = 0 114: let subdirCount = 0 115: for (const d of dirents) { 116: if (d.isFile()) { 117: fileCount++ 118: } else if (d.isDirectory()) { 119: subdirCount++ 120: } 121: } 122: logEvent('tengu_memdir_loaded', { 123: ...baseMetadata, 124: total_file_count: fileCount, 125: total_subdir_count: subdirCount, 126: }) 127: }, 128: () => { 129: logEvent('tengu_memdir_loaded', baseMetadata) 130: }, 131: ) 132: } 133: export function buildMemoryLines( 134: displayName: string, 135: memoryDir: string, 136: extraGuidelines?: string[], 137: skipIndex = false, 138: ): string[] { 139: const howToSave = skipIndex 140: ? [ 141: '## How to save memories', 142: '', 143: 'Write each memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:', 144: '', 145: ...MEMORY_FRONTMATTER_EXAMPLE, 146: '', 147: '- Keep the name, description, and type fields in memory files up-to-date with the content', 148: '- Organize memory semantically by topic, not chronologically', 149: '- Update or remove memories that turn out to be wrong or outdated', 150: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 151: ] 152: : [ 153: '## How to save memories', 154: '', 155: 'Saving a memory is a two-step process:', 156: '', 157: '**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:', 158: '', 159: ...MEMORY_FRONTMATTER_EXAMPLE, 160: '', 161: `**Step 2** — add a pointer to that file in \`${ENTRYPOINT_NAME}\`. \`${ENTRYPOINT_NAME}\` is an index, not a memory — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. It has no frontmatter. Never write memory content directly into \`${ENTRYPOINT_NAME}\`.`, 162: '', 163: `- \`${ENTRYPOINT_NAME}\` is always loaded into your conversation context — lines after ${MAX_ENTRYPOINT_LINES} will be truncated, so keep the index concise`, 164: '- Keep the name, description, and type fields in memory files up-to-date with the content', 165: '- Organize memory semantically by topic, not chronologically', 166: '- Update or remove memories that turn out to be wrong or outdated', 167: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 168: ] 169: const lines: string[] = [ 170: `# ${displayName}`, 171: '', 172: `You have a persistent, file-based memory system at \`${memoryDir}\`. ${DIR_EXISTS_GUIDANCE}`, 173: '', 174: "You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.", 175: '', 176: 'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.', 177: '', 178: ...TYPES_SECTION_INDIVIDUAL, 179: ...WHAT_NOT_TO_SAVE_SECTION, 180: '', 181: ...howToSave, 182: '', 183: ...WHEN_TO_ACCESS_SECTION, 184: '', 185: ...TRUSTING_RECALL_SECTION, 186: '', 187: '## Memory and other forms of persistence', 188: 'Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.', 189: '- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.', 190: '- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.', 191: '', 192: ...(extraGuidelines ?? []), 193: '', 194: ] 195: lines.push(...buildSearchingPastContextSection(memoryDir)) 196: return lines 197: } 198: /** 199: * Build the typed-memory prompt with MEMORY.md content included. 200: * Used by agent memory (which has no getClaudeMds() equivalent). 201: */ 202: export function buildMemoryPrompt(params: { 203: displayName: string 204: memoryDir: string 205: extraGuidelines?: string[] 206: }): string { 207: const { displayName, memoryDir, extraGuidelines } = params 208: const fs = getFsImplementation() 209: const entrypoint = memoryDir + ENTRYPOINT_NAME 210: // Directory creation is the caller's responsibility (loadMemoryPrompt / 211: // loadAgentMemoryPrompt). Builders only read, they don't mkdir. 212: // Read existing memory entrypoint (sync: prompt building is synchronous) 213: let entrypointContent = '' 214: try { 215: // eslint-disable-next-line custom-rules/no-sync-fs 216: entrypointContent = fs.readFileSync(entrypoint, { encoding: 'utf-8' }) 217: } catch { 218: // No memory file yet 219: } 220: const lines = buildMemoryLines(displayName, memoryDir, extraGuidelines) 221: if (entrypointContent.trim()) { 222: const t = truncateEntrypointContent(entrypointContent) 223: const memoryType = displayName === AUTO_MEM_DISPLAY_NAME ? 'auto' : 'agent' 224: logMemoryDirCounts(memoryDir, { 225: content_length: t.byteCount, 226: line_count: t.lineCount, 227: was_truncated: t.wasLineTruncated, 228: was_byte_truncated: t.wasByteTruncated, 229: memory_type: 230: memoryType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 231: }) 232: lines.push(`## ${ENTRYPOINT_NAME}`, '', t.content) 233: } else { 234: lines.push( 235: `## ${ENTRYPOINT_NAME}`, 236: '', 237: `Your ${ENTRYPOINT_NAME} is currently empty. When you save new memories, they will appear here.`, 238: ) 239: } 240: return lines.join('\n') 241: } 242: /** 243: * Assistant-mode daily-log prompt. Gated behind feature('KAIROS'). 244: * 245: * Assistant sessions are effectively perpetual, so the agent writes memories 246: * append-only to a date-named log file rather than maintaining MEMORY.md as 247: * a live index. A separate nightly /dream skill distills logs into topic 248: * files + MEMORY.md. MEMORY.md is still loaded into context (via claudemd.ts) 249: * as the distilled index — this prompt only changes where NEW memories go. 250: */ 251: function buildAssistantDailyLogPrompt(skipIndex = false): string { 252: const memoryDir = getAutoMemPath() 253: // Describe the path as a pattern rather than inlining today's literal path: 254: // this prompt is cached by systemPromptSection('memory', ...) and NOT 255: // invalidated on date change. The model derives the current date from the 256: // date_change attachment (appended at the tail on midnight rollover) rather 257: // than the user-context message — the latter is intentionally left stale to 258: // preserve the prompt cache prefix across midnight. 259: const logPathPattern = join(memoryDir, 'logs', 'YYYY', 'MM', 'YYYY-MM-DD.md') 260: const lines: string[] = [ 261: '# auto memory', 262: '', 263: `You have a persistent, file-based memory system found at: \`${memoryDir}\``, 264: '', 265: "This session is long-lived. As you work, record anything worth remembering by **appending** to today's daily log file:", 266: '', 267: `\`${logPathPattern}\``, 268: '', 269: "Substitute today's date (from `currentDate` in your context) for `YYYY-MM-DD`. When the date rolls over mid-session, start appending to the new day's file.", 270: '', 271: 'Write each entry as a short timestamped bullet. Create the file (and parent directories) on first write if it does not exist. Do not rewrite or reorganize the log — it is append-only. A separate nightly process distills these logs into `MEMORY.md` and topic files.', 272: '', 273: '## What to log', 274: '- User corrections and preferences ("use bun, not npm"; "stop summarizing diffs")', 275: '- Facts about the user, their role, or their goals', 276: '- Project context that is not derivable from the code (deadlines, incidents, decisions and their rationale)', 277: '- Pointers to external systems (dashboards, Linear projects, Slack channels)', 278: '- Anything the user explicitly asks you to remember', 279: '', 280: ...WHAT_NOT_TO_SAVE_SECTION, 281: '', 282: ...(skipIndex 283: ? [] 284: : [ 285: `## ${ENTRYPOINT_NAME}`, 286: `\`${ENTRYPOINT_NAME}\` is the distilled index (maintained nightly from your logs) and is loaded into your context automatically. Read it for orientation, but do not edit it directly — record new information in today's log instead.`, 287: '', 288: ]), 289: ...buildSearchingPastContextSection(memoryDir), 290: ] 291: return lines.join('\n') 292: } 293: /** 294: * Build the "Searching past context" section if the feature gate is enabled. 295: */ 296: export function buildSearchingPastContextSection(autoMemDir: string): string[] { 297: if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_coral_fern', false)) { 298: return [] 299: } 300: const projectDir = getProjectDir(getOriginalCwd()) 301: // Ant-native builds alias grep to embedded ugrep and remove the dedicated 302: // Grep tool, so give the model a real shell invocation there. 303: // In REPL mode, both Grep and Bash are hidden from direct use — the model 304: // calls them from inside REPL scripts, so the grep shell form is what it 305: // will write in the script anyway. 306: const embedded = hasEmbeddedSearchTools() || isReplModeEnabled() 307: const memSearch = embedded 308: ? `grep -rn "<search term>" ${autoMemDir} --include="*.md"` 309: : `${GREP_TOOL_NAME} with pattern="<search term>" path="${autoMemDir}" glob="*.md"` 310: const transcriptSearch = embedded 311: ? `grep -rn "<search term>" ${projectDir}/ --include="*.jsonl"` 312: : `${GREP_TOOL_NAME} with pattern="<search term>" path="${projectDir}/" glob="*.jsonl"` 313: return [ 314: '## Searching past context', 315: '', 316: 'When looking for past context:', 317: '1. Search topic files in your memory directory:', 318: '```', 319: memSearch, 320: '```', 321: '2. Session transcript logs (last resort — large files, slow):', 322: '```', 323: transcriptSearch, 324: '```', 325: 'Use narrow search terms (error messages, file paths, function names) rather than broad keywords.', 326: '', 327: ] 328: } 329: /** 330: * Load the unified memory prompt for inclusion in the system prompt. 331: * Dispatches based on which memory systems are enabled: 332: * - auto + team: combined prompt (both directories) 333: * - auto only: memory lines (single directory) 334: * Team memory requires auto memory (enforced by isTeamMemoryEnabled), so 335: * there is no team-only branch. 336: * 337: * Returns null when auto memory is disabled. 338: */ 339: export async function loadMemoryPrompt(): Promise<string | null> { 340: const autoEnabled = isAutoMemoryEnabled() 341: const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE( 342: 'tengu_moth_copse', 343: false, 344: ) 345: // KAIROS daily-log mode takes precedence over TEAMMEM: the append-only 346: // log paradigm does not compose with team sync (which expects a shared 347: // MEMORY.md that both sides read + write). Gating on `autoEnabled` here 348: if (feature('KAIROS') && autoEnabled && getKairosActive()) { 349: logMemoryDirCounts(getAutoMemPath(), { 350: memory_type: 351: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 352: }) 353: return buildAssistantDailyLogPrompt(skipIndex) 354: } 355: const coworkExtraGuidelines = 356: process.env.CLAUDE_COWORK_MEMORY_EXTRA_GUIDELINES 357: const extraGuidelines = 358: coworkExtraGuidelines && coworkExtraGuidelines.trim().length > 0 359: ? [coworkExtraGuidelines] 360: : undefined 361: if (feature('TEAMMEM')) { 362: if (teamMemPaths!.isTeamMemoryEnabled()) { 363: const autoDir = getAutoMemPath() 364: const teamDir = teamMemPaths!.getTeamMemPath() 365: await ensureMemoryDirExists(teamDir) 366: logMemoryDirCounts(autoDir, { 367: memory_type: 368: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 369: }) 370: logMemoryDirCounts(teamDir, { 371: memory_type: 372: 'team' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 373: }) 374: return teamMemPrompts!.buildCombinedMemoryPrompt( 375: extraGuidelines, 376: skipIndex, 377: ) 378: } 379: } 380: if (autoEnabled) { 381: const autoDir = getAutoMemPath() 382: await ensureMemoryDirExists(autoDir) 383: logMemoryDirCounts(autoDir, { 384: memory_type: 385: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 386: }) 387: return buildMemoryLines( 388: 'auto memory', 389: autoDir, 390: extraGuidelines, 391: skipIndex, 392: ).join('\n') 393: } 394: logEvent('tengu_memdir_disabled', { 395: disabled_by_env_var: isEnvTruthy( 396: process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY, 397: ), 398: disabled_by_setting: 399: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY) && 400: getInitialSettings().autoMemoryEnabled === false, 401: }) 402: if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false)) { 403: logEvent('tengu_team_memdir_disabled', {}) 404: } 405: return null 406: }

File: src/memdir/memoryAge.ts

typescript 1: export function memoryAgeDays(mtimeMs: number): number { 2: return Math.max(0, Math.floor((Date.now() - mtimeMs) / 86_400_000)) 3: } 4: export function memoryAge(mtimeMs: number): string { 5: const d = memoryAgeDays(mtimeMs) 6: if (d === 0) return 'today' 7: if (d === 1) return 'yesterday' 8: return `${d} days ago` 9: } 10: export function memoryFreshnessText(mtimeMs: number): string { 11: const d = memoryAgeDays(mtimeMs) 12: if (d <= 1) return '' 13: return ( 14: `This memory is ${d} days old. ` + 15: `Memories are point-in-time observations, not live state — ` + 16: `claims about code behavior or file:line citations may be outdated. ` + 17: `Verify against current code before asserting as fact.` 18: ) 19: } 20: /** 21: * Per-memory staleness note wrapped in <system-reminder> tags. 22: * Returns '' for memories ≤ 1 day old. Use this for callers that 23: * don't add their own system-reminder wrapper (e.g. FileReadTool output). 24: */ 25: export function memoryFreshnessNote(mtimeMs: number): string { 26: const text = memoryFreshnessText(mtimeMs) 27: if (!text) return '' 28: return `<system-reminder>${text}</system-reminder>\n` 29: }

File: src/memdir/memoryScan.ts

typescript 1: import { readdir } from 'fs/promises' 2: import { basename, join } from 'path' 3: import { parseFrontmatter } from '../utils/frontmatterParser.js' 4: import { readFileInRange } from '../utils/readFileInRange.js' 5: import { type MemoryType, parseMemoryType } from './memoryTypes.js' 6: export type MemoryHeader = { 7: filename: string 8: filePath: string 9: mtimeMs: number 10: description: string | null 11: type: MemoryType | undefined 12: } 13: const MAX_MEMORY_FILES = 200 14: const FRONTMATTER_MAX_LINES = 30 15: export async function scanMemoryFiles( 16: memoryDir: string, 17: signal: AbortSignal, 18: ): Promise<MemoryHeader[]> { 19: try { 20: const entries = await readdir(memoryDir, { recursive: true }) 21: const mdFiles = entries.filter( 22: f => f.endsWith('.md') && basename(f) !== 'MEMORY.md', 23: ) 24: const headerResults = await Promise.allSettled( 25: mdFiles.map(async (relativePath): Promise<MemoryHeader> => { 26: const filePath = join(memoryDir, relativePath) 27: const { content, mtimeMs } = await readFileInRange( 28: filePath, 29: 0, 30: FRONTMATTER_MAX_LINES, 31: undefined, 32: signal, 33: ) 34: const { frontmatter } = parseFrontmatter(content, filePath) 35: return { 36: filename: relativePath, 37: filePath, 38: mtimeMs, 39: description: frontmatter.description || null, 40: type: parseMemoryType(frontmatter.type), 41: } 42: }), 43: ) 44: return headerResults 45: .filter( 46: (r): r is PromiseFulfilledResult<MemoryHeader> => 47: r.status === 'fulfilled', 48: ) 49: .map(r => r.value) 50: .sort((a, b) => b.mtimeMs - a.mtimeMs) 51: .slice(0, MAX_MEMORY_FILES) 52: } catch { 53: return [] 54: } 55: } 56: export function formatMemoryManifest(memories: MemoryHeader[]): string { 57: return memories 58: .map(m => { 59: const tag = m.type ? `[${m.type}] ` : '' 60: const ts = new Date(m.mtimeMs).toISOString() 61: return m.description 62: ? `- ${tag}${m.filename} (${ts}): ${m.description}` 63: : `- ${tag}${m.filename} (${ts})` 64: }) 65: .join('\n') 66: }

File: src/memdir/memoryTypes.ts

typescript 1: export const MEMORY_TYPES = [ 2: 'user', 3: 'feedback', 4: 'project', 5: 'reference', 6: ] as const 7: export type MemoryType = (typeof MEMORY_TYPES)[number] 8: export function parseMemoryType(raw: unknown): MemoryType | undefined { 9: if (typeof raw !== 'string') return undefined 10: return MEMORY_TYPES.find(t => t === raw) 11: } 12: export const TYPES_SECTION_COMBINED: readonly string[] = [ 13: '## Types of memory', 14: '', 15: 'There are several discrete types of memory that you can store in your memory system. Each type below declares a <scope> of `private`, `team`, or guidance for choosing between the two.', 16: '', 17: '<types>', 18: '<type>', 19: ' <name>user</name>', 20: ' <scope>always private</scope>', 21: " <description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>", 22: " <when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>", 23: " <how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>", 24: ' <examples>', 25: " user: I'm a data scientist investigating what logging we have in place", 26: ' assistant: [saves private user memory: user is a data scientist, currently focused on observability/logging]', 27: '', 28: " user: I've been writing Go for ten years but this is my first time touching the React side of this repo", 29: " assistant: [saves private user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]", 30: ' </examples>', 31: '</type>', 32: '<type>', 33: ' <name>feedback</name>', 34: ' <scope>default to private. Save as team only when the guidance is clearly a project-wide convention that every contributor should follow (e.g., a testing policy, a build invariant), not a personal style preference.</scope>', 35: " <description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious. Before saving a private feedback memory, check that it doesn't contradict a team feedback memory — if it does, either don't save it or note the override explicitly.</description>", 36: ' <when_to_save>Any time the user corrects your approach ("no not that", "don\'t", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>', 37: ' <how_to_use>Let these memories guide your behavior so that the user and other users in the project do not need to offer the same guidance twice.</how_to_use>', 38: ' <body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>', 39: ' <examples>', 40: " user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed", 41: ' assistant: [saves team feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration. Team scope: this is a project testing policy, not a personal preference]', 42: '', 43: ' user: stop summarizing what you just did at the end of every response, I can read the diff', 44: " assistant: [saves private feedback memory: this user wants terse responses with no trailing summaries. Private because it's a communication preference, not a project convention]", 45: '', 46: " user: yeah the single bundled PR was the right call here, splitting this one would've just been churn", 47: ' assistant: [saves private feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]', 48: ' </examples>', 49: '</type>', 50: '<type>', 51: ' <name>project</name>', 52: ' <scope>private or team, but strongly bias toward team</scope>', 53: ' <description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work users are working on within this working directory.</description>', 54: ' <when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>', 55: " <how_to_use>Use these memories to more fully understand the details and nuance behind the user's request, anticipate coordination issues across users, make better informed suggestions.</how_to_use>", 56: ' <body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>', 57: ' <examples>', 58: " user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch", 59: ' assistant: [saves team project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]', 60: '', 61: " user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements", 62: ' assistant: [saves team project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]', 63: ' </examples>', 64: '</type>', 65: '<type>', 66: ' <name>reference</name>', 67: ' <scope>usually team</scope>', 68: ' <description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>', 69: ' <when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>', 70: ' <how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>', 71: ' <examples>', 72: ' user: check the Linear project "INGEST" if you want context on these tickets, that\'s where we track all pipeline bugs', 73: ' assistant: [saves team reference memory: pipeline bugs are tracked in Linear project "INGEST"]', 74: '', 75: " user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone", 76: ' assistant: [saves team reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]', 77: ' </examples>', 78: '</type>', 79: '</types>', 80: '', 81: ] 82: /** 83: * `## Types of memory` section for INDIVIDUAL-ONLY mode (single directory). 84: * No <scope> tags. Examples use plain `[saves X memory: …]`. Prose that 85: * only makes sense with a private/team split is reworded. 86: */ 87: export const TYPES_SECTION_INDIVIDUAL: readonly string[] = [ 88: '## Types of memory', 89: '', 90: 'There are several discrete types of memory that you can store in your memory system:', 91: '', 92: '<types>', 93: '<type>', 94: ' <name>user</name>', 95: " <description>Contain information about the user's role, goals, responsibilities, and knowledge. Great user memories help you tailor your future behavior to the user's preferences and perspective. Your goal in reading and writing these memories is to build up an understanding of who the user is and how you can be most helpful to them specifically. For example, you should collaborate with a senior software engineer differently than a student who is coding for the very first time. Keep in mind, that the aim here is to be helpful to the user. Avoid writing memories about the user that could be viewed as a negative judgement or that are not relevant to the work you're trying to accomplish together.</description>", 96: " <when_to_save>When you learn any details about the user's role, preferences, responsibilities, or knowledge</when_to_save>", 97: " <how_to_use>When your work should be informed by the user's profile or perspective. For example, if the user is asking you to explain a part of the code, you should answer that question in a way that is tailored to the specific details that they will find most valuable or that helps them build their mental model in relation to domain knowledge they already have.</how_to_use>", 98: ' <examples>', 99: " user: I'm a data scientist investigating what logging we have in place", 100: ' assistant: [saves user memory: user is a data scientist, currently focused on observability/logging]', 101: '', 102: " user: I've been writing Go for ten years but this is my first time touching the React side of this repo", 103: " assistant: [saves user memory: deep Go expertise, new to React and this project's frontend — frame frontend explanations in terms of backend analogues]", 104: ' </examples>', 105: '</type>', 106: '<type>', 107: ' <name>feedback</name>', 108: ' <description>Guidance the user has given you about how to approach work — both what to avoid and what to keep doing. These are a very important type of memory to read and write as they allow you to remain coherent and responsive to the way you should approach work in the project. Record from failure AND success: if you only save corrections, you will avoid past mistakes but drift away from approaches the user has already validated, and may grow overly cautious.</description>', 109: ' <when_to_save>Any time the user corrects your approach ("no not that", "don\'t", "stop doing X") OR confirms a non-obvious approach worked ("yes exactly", "perfect, keep doing that", accepting an unusual choice without pushback). Corrections are easy to notice; confirmations are quieter — watch for them. In both cases, save what is applicable to future conversations, especially if surprising or not obvious from the code. Include *why* so you can judge edge cases later.</when_to_save>', 110: ' <how_to_use>Let these memories guide your behavior so that the user does not need to offer the same guidance twice.</how_to_use>', 111: ' <body_structure>Lead with the rule itself, then a **Why:** line (the reason the user gave — often a past incident or strong preference) and a **How to apply:** line (when/where this guidance kicks in). Knowing *why* lets you judge edge cases instead of blindly following the rule.</body_structure>', 112: ' <examples>', 113: " user: don't mock the database in these tests — we got burned last quarter when mocked tests passed but the prod migration failed", 114: ' assistant: [saves feedback memory: integration tests must hit a real database, not mocks. Reason: prior incident where mock/prod divergence masked a broken migration]', 115: '', 116: ' user: stop summarizing what you just did at the end of every response, I can read the diff', 117: ' assistant: [saves feedback memory: this user wants terse responses with no trailing summaries]', 118: '', 119: " user: yeah the single bundled PR was the right call here, splitting this one would've just been churn", 120: ' assistant: [saves feedback memory: for refactors in this area, user prefers one bundled PR over many small ones. Confirmed after I chose this approach — a validated judgment call, not a correction]', 121: ' </examples>', 122: '</type>', 123: '<type>', 124: ' <name>project</name>', 125: ' <description>Information that you learn about ongoing work, goals, initiatives, bugs, or incidents within the project that is not otherwise derivable from the code or git history. Project memories help you understand the broader context and motivation behind the work the user is doing within this working directory.</description>', 126: ' <when_to_save>When you learn who is doing what, why, or by when. These states change relatively quickly so try to keep your understanding of this up to date. Always convert relative dates in user messages to absolute dates when saving (e.g., "Thursday" → "2026-03-05"), so the memory remains interpretable after time passes.</when_to_save>', 127: " <how_to_use>Use these memories to more fully understand the details and nuance behind the user's request and make better informed suggestions.</how_to_use>", 128: ' <body_structure>Lead with the fact or decision, then a **Why:** line (the motivation — often a constraint, deadline, or stakeholder ask) and a **How to apply:** line (how this should shape your suggestions). Project memories decay fast, so the why helps future-you judge whether the memory is still load-bearing.</body_structure>', 129: ' <examples>', 130: " user: we're freezing all non-critical merges after Thursday — mobile team is cutting a release branch", 131: ' assistant: [saves project memory: merge freeze begins 2026-03-05 for mobile release cut. Flag any non-critical PR work scheduled after that date]', 132: '', 133: " user: the reason we're ripping out the old auth middleware is that legal flagged it for storing session tokens in a way that doesn't meet the new compliance requirements", 134: ' assistant: [saves project memory: auth middleware rewrite is driven by legal/compliance requirements around session token storage, not tech-debt cleanup — scope decisions should favor compliance over ergonomics]', 135: ' </examples>', 136: '</type>', 137: '<type>', 138: ' <name>reference</name>', 139: ' <description>Stores pointers to where information can be found in external systems. These memories allow you to remember where to look to find up-to-date information outside of the project directory.</description>', 140: ' <when_to_save>When you learn about resources in external systems and their purpose. For example, that bugs are tracked in a specific project in Linear or that feedback can be found in a specific Slack channel.</when_to_save>', 141: ' <how_to_use>When the user references an external system or information that may be in an external system.</how_to_use>', 142: ' <examples>', 143: ' user: check the Linear project "INGEST" if you want context on these tickets, that\'s where we track all pipeline bugs', 144: ' assistant: [saves reference memory: pipeline bugs are tracked in Linear project "INGEST"]', 145: '', 146: " user: the Grafana board at grafana.internal/d/api-latency is what oncall watches — if you're touching request handling, that's the thing that'll page someone", 147: ' assistant: [saves reference memory: grafana.internal/d/api-latency is the oncall latency dashboard — check it when editing request-path code]', 148: ' </examples>', 149: '</type>', 150: '</types>', 151: '', 152: ] 153: /** 154: * `## What NOT to save in memory` section. Identical across both modes. 155: */ 156: export const WHAT_NOT_TO_SAVE_SECTION: readonly string[] = [ 157: '## What NOT to save in memory', 158: '', 159: '- Code patterns, conventions, architecture, file paths, or project structure — these can be derived by reading the current project state.', 160: '- Git history, recent changes, or who-changed-what — `git log` / `git blame` are authoritative.', 161: '- Debugging solutions or fix recipes — the fix is in the code; the commit message has the context.', 162: '- Anything already documented in CLAUDE.md files.', 163: '- Ephemeral task details: in-progress work, temporary state, current conversation context.', 164: '', 165: // H2: explicit-save gate. Eval-validated (memory-prompt-iteration case 3, 166: // 0/2 → 3/3): prevents "save this week's PR list" → activity-log noise. 167: 'These exclusions apply even when the user explicitly asks you to save. If they ask you to save a PR list or activity summary, ask what was *surprising* or *non-obvious* about it — that is the part worth keeping.', 168: ] 169: export const MEMORY_DRIFT_CAVEAT = 170: '- Memory records can become stale over time. Use memory as context for what was true at a given point in time. Before answering the user or building assumptions based solely on information in memory records, verify that the memory is still correct and up-to-date by reading the current state of the files or resources. If a recalled memory conflicts with current information, trust what you observe now — and update or remove the stale memory rather than acting on it.' 171: export const WHEN_TO_ACCESS_SECTION: readonly string[] = [ 172: '## When to access memories', 173: '- When memories seem relevant, or the user references prior-conversation work.', 174: '- You MUST access memory when the user explicitly asks you to check, recall, or remember.', 175: '- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content.', 176: MEMORY_DRIFT_CAVEAT, 177: ] 178: export const TRUSTING_RECALL_SECTION: readonly string[] = [ 179: '## Before recommending from memory', 180: '', 181: 'A memory that names a specific function, file, or flag is a claim that it existed *when the memory was written*. It may have been renamed, removed, or never merged. Before recommending it:', 182: '', 183: '- If the memory names a file path: check the file exists.', 184: '- If the memory names a function or flag: grep for it.', 185: '- If the user is about to act on your recommendation (not just asking about history), verify first.', 186: '', 187: '"The memory says X exists" is not the same as "X exists now."', 188: '', 189: 'A memory that summarizes repo state (activity logs, architecture snapshots) is frozen in time. If the user asks about *recent* or *current* state, prefer `git log` or reading the code over recalling the snapshot.', 190: ] 191: /** 192: * Frontmatter format example with the `type` field. 193: */ 194: export const MEMORY_FRONTMATTER_EXAMPLE: readonly string[] = [ 195: '```markdown', 196: '---', 197: 'name: {{memory name}}', 198: 'description: {{one-line description — used to decide relevance in future conversations, so be specific}}', 199: `type: {{${MEMORY_TYPES.join(', ')}}}`, 200: '---', 201: '', 202: '{{memory content — for feedback/project types, structure as: rule/fact, then **Why:** and **How to apply:** lines}}', 203: '```', 204: ]

File: src/memdir/paths.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import { homedir } from 'os' 3: import { isAbsolute, join, normalize, sep } from 'path' 4: import { 5: getIsNonInteractiveSession, 6: getProjectRoot, 7: } from '../bootstrap/state.js' 8: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 9: import { 10: getClaudeConfigHomeDir, 11: isEnvDefinedFalsy, 12: isEnvTruthy, 13: } from '../utils/envUtils.js' 14: import { findCanonicalGitRoot } from '../utils/git.js' 15: import { sanitizePath } from '../utils/path.js' 16: import { 17: getInitialSettings, 18: getSettingsForSource, 19: } from '../utils/settings/settings.js' 20: export function isAutoMemoryEnabled(): boolean { 21: const envVal = process.env.CLAUDE_CODE_DISABLE_AUTO_MEMORY 22: if (isEnvTruthy(envVal)) { 23: return false 24: } 25: if (isEnvDefinedFalsy(envVal)) { 26: return true 27: } 28: if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 29: return false 30: } 31: if ( 32: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && 33: !process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR 34: ) { 35: return false 36: } 37: const settings = getInitialSettings() 38: if (settings.autoMemoryEnabled !== undefined) { 39: return settings.autoMemoryEnabled 40: } 41: return true 42: } 43: export function isExtractModeActive(): boolean { 44: if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) { 45: return false 46: } 47: return ( 48: !getIsNonInteractiveSession() || 49: getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_thimble', false) 50: ) 51: } 52: export function getMemoryBaseDir(): string { 53: if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) { 54: return process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR 55: } 56: return getClaudeConfigHomeDir() 57: } 58: const AUTO_MEM_DIRNAME = 'memory' 59: const AUTO_MEM_ENTRYPOINT_NAME = 'MEMORY.md' 60: function validateMemoryPath( 61: raw: string | undefined, 62: expandTilde: boolean, 63: ): string | undefined { 64: if (!raw) { 65: return undefined 66: } 67: let candidate = raw 68: if ( 69: expandTilde && 70: (candidate.startsWith('~/') || candidate.startsWith('~\\')) 71: ) { 72: const rest = candidate.slice(2) 73: // Reject trivial remainders that would expand to $HOME or an ancestor. 74: // normalize('') = '.', normalize('.') = '.', normalize('foo/..') = '.', 75: // normalize('..') = '..', normalize('foo/../..') = '..' 76: const restNorm = normalize(rest || '.') 77: if (restNorm === '.' || restNorm === '..') { 78: return undefined 79: } 80: candidate = join(homedir(), rest) 81: } 82: // normalize() may preserve a trailing separator; strip before adding 83: // exactly one to match the trailing-sep contract of getAutoMemPath() 84: const normalized = normalize(candidate).replace(/[/\\]+$/, '') 85: if ( 86: !isAbsolute(normalized) || 87: normalized.length < 3 || 88: /^[A-Za-z]:$/.test(normalized) || 89: normalized.startsWith('\\\\') || 90: normalized.startsWith(' 91: normalized.includes('\0') 92: ) { 93: return undefined 94: } 95: return (normalized + sep).normalize('NFC') 96: } 97: function getAutoMemPathOverride(): string | undefined { 98: return validateMemoryPath( 99: process.env.CLAUDE_COWORK_MEMORY_PATH_OVERRIDE, 100: false, 101: ) 102: } 103: function getAutoMemPathSetting(): string | undefined { 104: const dir = 105: getSettingsForSource('policySettings')?.autoMemoryDirectory ?? 106: getSettingsForSource('flagSettings')?.autoMemoryDirectory ?? 107: getSettingsForSource('localSettings')?.autoMemoryDirectory ?? 108: getSettingsForSource('userSettings')?.autoMemoryDirectory 109: return validateMemoryPath(dir, true) 110: } 111: export function hasAutoMemPathOverride(): boolean { 112: return getAutoMemPathOverride() !== undefined 113: } 114: function getAutoMemBase(): string { 115: return findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot() 116: } 117: export const getAutoMemPath = memoize( 118: (): string => { 119: const override = getAutoMemPathOverride() ?? getAutoMemPathSetting() 120: if (override) { 121: return override 122: } 123: const projectsDir = join(getMemoryBaseDir(), 'projects') 124: return ( 125: join(projectsDir, sanitizePath(getAutoMemBase()), AUTO_MEM_DIRNAME) + sep 126: ).normalize('NFC') 127: }, 128: () => getProjectRoot(), 129: ) 130: export function getAutoMemDailyLogPath(date: Date = new Date()): string { 131: const yyyy = date.getFullYear().toString() 132: const mm = (date.getMonth() + 1).toString().padStart(2, '0') 133: const dd = date.getDate().toString().padStart(2, '0') 134: return join(getAutoMemPath(), 'logs', yyyy, mm, `${yyyy}-${mm}-${dd}.md`) 135: } 136: export function getAutoMemEntrypoint(): string { 137: return join(getAutoMemPath(), AUTO_MEM_ENTRYPOINT_NAME) 138: } 139: export function isAutoMemPath(absolutePath: string): boolean { 140: const normalizedPath = normalize(absolutePath) 141: return normalizedPath.startsWith(getAutoMemPath()) 142: }

File: src/memdir/teamMemPaths.ts

typescript 1: import { lstat, realpath } from 'fs/promises' 2: import { dirname, join, resolve, sep } from 'path' 3: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 4: import { getErrnoCode } from '../utils/errors.js' 5: import { getAutoMemPath, isAutoMemoryEnabled } from './paths.js' 6: export class PathTraversalError extends Error { 7: constructor(message: string) { 8: super(message) 9: this.name = 'PathTraversalError' 10: } 11: } 12: function sanitizePathKey(key: string): string { 13: if (key.includes('\0')) { 14: throw new PathTraversalError(`Null byte in path key: "${key}"`) 15: } 16: let decoded: string 17: try { 18: decoded = decodeURIComponent(key) 19: } catch { 20: decoded = key 21: } 22: if (decoded !== key && (decoded.includes('..') || decoded.includes('/'))) { 23: throw new PathTraversalError(`URL-encoded traversal in path key: "${key}"`) 24: } 25: const normalized = key.normalize('NFKC') 26: if ( 27: normalized !== key && 28: (normalized.includes('..') || 29: normalized.includes('/') || 30: normalized.includes('\\') || 31: normalized.includes('\0')) 32: ) { 33: throw new PathTraversalError( 34: `Unicode-normalized traversal in path key: "${key}"`, 35: ) 36: } 37: if (key.includes('\\')) { 38: throw new PathTraversalError(`Backslash in path key: "${key}"`) 39: } 40: // Reject absolute paths 41: if (key.startsWith('/')) { 42: throw new PathTraversalError(`Absolute path key: "${key}"`) 43: } 44: return key 45: } 46: /** 47: * Whether team memory features are enabled. 48: * Team memory is a subdirectory of auto memory, so it requires auto memory 49: * to be enabled. This keeps all team-memory consumers (prompt, content 50: * injection, sync watcher, file detection) consistent when auto memory is 51: * disabled via env var or settings. 52: */ 53: export function isTeamMemoryEnabled(): boolean { 54: if (!isAutoMemoryEnabled()) { 55: return false 56: } 57: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_herring_clock', false) 58: } 59: export function getTeamMemPath(): string { 60: return (join(getAutoMemPath(), 'team') + sep).normalize('NFC') 61: } 62: export function getTeamMemEntrypoint(): string { 63: return join(getAutoMemPath(), 'team', 'MEMORY.md') 64: } 65: async function realpathDeepestExisting(absolutePath: string): Promise<string> { 66: const tail: string[] = [] 67: let current = absolutePath 68: for ( 69: let parent = dirname(current); 70: current !== parent; 71: parent = dirname(current) 72: ) { 73: try { 74: const realCurrent = await realpath(current) 75: return tail.length === 0 76: ? realCurrent 77: : join(realCurrent, ...tail.reverse()) 78: } catch (e: unknown) { 79: const code = getErrnoCode(e) 80: if (code === 'ENOENT') { 81: try { 82: const st = await lstat(current) 83: if (st.isSymbolicLink()) { 84: throw new PathTraversalError( 85: `Dangling symlink detected (target does not exist): "${current}"`, 86: ) 87: } 88: } catch (lstatErr: unknown) { 89: if (lstatErr instanceof PathTraversalError) { 90: throw lstatErr 91: } 92: } 93: } else if (code === 'ELOOP') { 94: throw new PathTraversalError( 95: `Symlink loop detected in path: "${current}"`, 96: ) 97: } else if (code !== 'ENOTDIR' && code !== 'ENAMETOOLONG') { 98: throw new PathTraversalError( 99: `Cannot verify path containment (${code}): "${current}"`, 100: ) 101: } 102: tail.push(current.slice(parent.length + sep.length)) 103: current = parent 104: } 105: } 106: return absolutePath 107: } 108: async function isRealPathWithinTeamDir( 109: realCandidate: string, 110: ): Promise<boolean> { 111: let realTeamDir: string 112: try { 113: realTeamDir = await realpath(getTeamMemPath().replace(/[/\\]+$/, '')) 114: } catch (e: unknown) { 115: const code = getErrnoCode(e) 116: if (code === 'ENOENT' || code === 'ENOTDIR') { 117: return true 118: } 119: return false 120: } 121: if (realCandidate === realTeamDir) { 122: return true 123: } 124: return realCandidate.startsWith(realTeamDir + sep) 125: } 126: export function isTeamMemPath(filePath: string): boolean { 127: const resolvedPath = resolve(filePath) 128: const teamDir = getTeamMemPath() 129: return resolvedPath.startsWith(teamDir) 130: } 131: export async function validateTeamMemWritePath( 132: filePath: string, 133: ): Promise<string> { 134: if (filePath.includes('\0')) { 135: throw new PathTraversalError(`Null byte in path: "${filePath}"`) 136: } 137: const resolvedPath = resolve(filePath) 138: const teamDir = getTeamMemPath() 139: if (!resolvedPath.startsWith(teamDir)) { 140: throw new PathTraversalError( 141: `Path escapes team memory directory: "${filePath}"`, 142: ) 143: } 144: const realPath = await realpathDeepestExisting(resolvedPath) 145: if (!(await isRealPathWithinTeamDir(realPath))) { 146: throw new PathTraversalError( 147: `Path escapes team memory directory via symlink: "${filePath}"`, 148: ) 149: } 150: return resolvedPath 151: } 152: export async function validateTeamMemKey(relativeKey: string): Promise<string> { 153: sanitizePathKey(relativeKey) 154: const teamDir = getTeamMemPath() 155: const fullPath = join(teamDir, relativeKey) 156: const resolvedPath = resolve(fullPath) 157: if (!resolvedPath.startsWith(teamDir)) { 158: throw new PathTraversalError( 159: `Key escapes team memory directory: "${relativeKey}"`, 160: ) 161: } 162: const realPath = await realpathDeepestExisting(resolvedPath) 163: if (!(await isRealPathWithinTeamDir(realPath))) { 164: throw new PathTraversalError( 165: `Key escapes team memory directory via symlink: "${relativeKey}"`, 166: ) 167: } 168: return resolvedPath 169: } 170: export function isTeamMemFile(filePath: string): boolean { 171: return isTeamMemoryEnabled() && isTeamMemPath(filePath) 172: }

File: src/memdir/teamMemPrompts.ts

typescript 1: import { 2: buildSearchingPastContextSection, 3: DIRS_EXIST_GUIDANCE, 4: ENTRYPOINT_NAME, 5: MAX_ENTRYPOINT_LINES, 6: } from './memdir.js' 7: import { 8: MEMORY_DRIFT_CAVEAT, 9: MEMORY_FRONTMATTER_EXAMPLE, 10: TRUSTING_RECALL_SECTION, 11: TYPES_SECTION_COMBINED, 12: WHAT_NOT_TO_SAVE_SECTION, 13: } from './memoryTypes.js' 14: import { getAutoMemPath } from './paths.js' 15: import { getTeamMemPath } from './teamMemPaths.js' 16: export function buildCombinedMemoryPrompt( 17: extraGuidelines?: string[], 18: skipIndex = false, 19: ): string { 20: const autoDir = getAutoMemPath() 21: const teamDir = getTeamMemPath() 22: const howToSave = skipIndex 23: ? [ 24: '## How to save memories', 25: '', 26: "Write each memory to its own file in the chosen directory (private or team, per the type's scope guidance) using this frontmatter format:", 27: '', 28: ...MEMORY_FRONTMATTER_EXAMPLE, 29: '', 30: '- Keep the name, description, and type fields in memory files up-to-date with the content', 31: '- Organize memory semantically by topic, not chronologically', 32: '- Update or remove memories that turn out to be wrong or outdated', 33: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 34: ] 35: : [ 36: '## How to save memories', 37: '', 38: 'Saving a memory is a two-step process:', 39: '', 40: "**Step 1** — write the memory to its own file in the chosen directory (private or team, per the type's scope guidance) using this frontmatter format:", 41: '', 42: ...MEMORY_FRONTMATTER_EXAMPLE, 43: '', 44: `**Step 2** — add a pointer to that file in the same directory's \`${ENTRYPOINT_NAME}\`. Each directory (private and team) has its own \`${ENTRYPOINT_NAME}\` index — each entry should be one line, under ~150 characters: \`- [Title](file.md) — one-line hook\`. They have no frontmatter. Never write memory content directly into a \`${ENTRYPOINT_NAME}\`.`, 45: '', 46: `- Both \`${ENTRYPOINT_NAME}\` indexes are loaded into your conversation context — lines after ${MAX_ENTRYPOINT_LINES} will be truncated, so keep them concise`, 47: '- Keep the name, description, and type fields in memory files up-to-date with the content', 48: '- Organize memory semantically by topic, not chronologically', 49: '- Update or remove memories that turn out to be wrong or outdated', 50: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 51: ] 52: const lines = [ 53: '# Memory', 54: '', 55: `You have a persistent, file-based memory system with two directories: a private directory at \`${autoDir}\` and a shared team directory at \`${teamDir}\`. ${DIRS_EXIST_GUIDANCE}`, 56: '', 57: "You should build up this memory system over time so that future conversations can have a complete picture of who the user is, how they'd like to collaborate with you, what behaviors to avoid or repeat, and the context behind the work the user gives you.", 58: '', 59: 'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.', 60: '', 61: '## Memory scope', 62: '', 63: 'There are two scope levels:', 64: '', 65: `- private: memories that are private between you and the current user. They persist across conversations with only this specific user and are stored at the root \`${autoDir}\`.`, 66: `- team: memories that are shared with and contributed by all of the users who work within this project directory. Team memories are synced at the beginning of every session and they are stored at \`${teamDir}\`.`, 67: '', 68: ...TYPES_SECTION_COMBINED, 69: ...WHAT_NOT_TO_SAVE_SECTION, 70: '- You MUST avoid saving sensitive data within shared team memories. For example, never save API keys or user credentials.', 71: '', 72: ...howToSave, 73: '', 74: '## When to access memories', 75: '- When memories (personal or team) seem relevant, or the user references prior work with them or others in their organization.', 76: '- You MUST access memory when the user explicitly asks you to check, recall, or remember.', 77: '- If the user says to *ignore* or *not use* memory: proceed as if MEMORY.md were empty. Do not apply remembered facts, cite, compare against, or mention memory content.', 78: MEMORY_DRIFT_CAVEAT, 79: '', 80: ...TRUSTING_RECALL_SECTION, 81: '', 82: '## Memory and other forms of persistence', 83: 'Memory is one of several persistence mechanisms available to you as you assist the user in a given conversation. The distinction is often that memory can be recalled in future conversations and should not be used for persisting information that is only useful within the scope of the current conversation.', 84: '- When to use or update a plan instead of memory: If you are about to start a non-trivial implementation task and would like to reach alignment with the user on your approach you should use a Plan rather than saving this information to memory. Similarly, if you already have a plan within the conversation and you have changed your approach persist that change by updating the plan rather than saving a memory.', 85: '- When to use or update tasks instead of memory: When you need to break your work in current conversation into discrete steps or keep track of your progress use tasks instead of saving to memory. Tasks are great for persisting information about the work that needs to be done in the current conversation, but memory should be reserved for information that will be useful in future conversations.', 86: ...(extraGuidelines ?? []), 87: '', 88: ...buildSearchingPastContextSection(autoDir), 89: ] 90: return lines.join('\n') 91: }

File: src/migrations/migrateAutoUpdatesToSettings.ts

typescript 1: import { logEvent } from 'src/services/analytics/index.js' 2: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 3: import { logError } from '../utils/log.js' 4: import { 5: getSettingsForSource, 6: updateSettingsForSource, 7: } from '../utils/settings/settings.js' 8: export function migrateAutoUpdatesToSettings(): void { 9: const globalConfig = getGlobalConfig() 10: if ( 11: globalConfig.autoUpdates !== false || 12: globalConfig.autoUpdatesProtectedForNative === true 13: ) { 14: return 15: } 16: try { 17: const userSettings = getSettingsForSource('userSettings') || {} 18: updateSettingsForSource('userSettings', { 19: ...userSettings, 20: env: { 21: ...userSettings.env, 22: DISABLE_AUTOUPDATER: '1', 23: }, 24: }) 25: logEvent('tengu_migrate_autoupdates_to_settings', { 26: was_user_preference: true, 27: already_had_env_var: !!userSettings.env?.DISABLE_AUTOUPDATER, 28: }) 29: process.env.DISABLE_AUTOUPDATER = '1' 30: saveGlobalConfig(current => { 31: const { 32: autoUpdates: _, 33: autoUpdatesProtectedForNative: __, 34: ...updatedConfig 35: } = current 36: return updatedConfig 37: }) 38: } catch (error) { 39: logError(new Error(`Failed to migrate auto-updates: ${error}`)) 40: logEvent('tengu_migrate_autoupdates_error', { 41: has_error: true, 42: }) 43: } 44: }

File: src/migrations/migrateBypassPermissionsAcceptedToSettings.ts

typescript 1: import { logEvent } from 'src/services/analytics/index.js' 2: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 3: import { logError } from '../utils/log.js' 4: import { 5: hasSkipDangerousModePermissionPrompt, 6: updateSettingsForSource, 7: } from '../utils/settings/settings.js' 8: export function migrateBypassPermissionsAcceptedToSettings(): void { 9: const globalConfig = getGlobalConfig() 10: if (!globalConfig.bypassPermissionsModeAccepted) { 11: return 12: } 13: try { 14: if (!hasSkipDangerousModePermissionPrompt()) { 15: updateSettingsForSource('userSettings', { 16: skipDangerousModePermissionPrompt: true, 17: }) 18: } 19: logEvent('tengu_migrate_bypass_permissions_accepted', {}) 20: saveGlobalConfig(current => { 21: if (!('bypassPermissionsModeAccepted' in current)) return current 22: const { bypassPermissionsModeAccepted: _, ...updatedConfig } = current 23: return updatedConfig 24: }) 25: } catch (error) { 26: logError( 27: new Error(`Failed to migrate bypass permissions accepted: ${error}`), 28: ) 29: } 30: }

File: src/migrations/migrateEnableAllProjectMcpServersToSettings.ts

typescript 1: import { logEvent } from 'src/services/analytics/index.js' 2: import { 3: getCurrentProjectConfig, 4: saveCurrentProjectConfig, 5: } from '../utils/config.js' 6: import { logError } from '../utils/log.js' 7: import { 8: getSettingsForSource, 9: updateSettingsForSource, 10: } from '../utils/settings/settings.js' 11: export function migrateEnableAllProjectMcpServersToSettings(): void { 12: const projectConfig = getCurrentProjectConfig() 13: const hasEnableAll = projectConfig.enableAllProjectMcpServers !== undefined 14: const hasEnabledServers = 15: projectConfig.enabledMcpjsonServers && 16: projectConfig.enabledMcpjsonServers.length > 0 17: const hasDisabledServers = 18: projectConfig.disabledMcpjsonServers && 19: projectConfig.disabledMcpjsonServers.length > 0 20: if (!hasEnableAll && !hasEnabledServers && !hasDisabledServers) { 21: return 22: } 23: try { 24: const existingSettings = getSettingsForSource('localSettings') || {} 25: const updates: Partial<{ 26: enableAllProjectMcpServers: boolean 27: enabledMcpjsonServers: string[] 28: disabledMcpjsonServers: string[] 29: }> = {} 30: const fieldsToRemove: Array< 31: | 'enableAllProjectMcpServers' 32: | 'enabledMcpjsonServers' 33: | 'disabledMcpjsonServers' 34: > = [] 35: if ( 36: hasEnableAll && 37: existingSettings.enableAllProjectMcpServers === undefined 38: ) { 39: updates.enableAllProjectMcpServers = 40: projectConfig.enableAllProjectMcpServers 41: fieldsToRemove.push('enableAllProjectMcpServers') 42: } else if (hasEnableAll) { 43: fieldsToRemove.push('enableAllProjectMcpServers') 44: } 45: if (hasEnabledServers && projectConfig.enabledMcpjsonServers) { 46: const existingEnabledServers = 47: existingSettings.enabledMcpjsonServers || [] 48: updates.enabledMcpjsonServers = [ 49: ...new Set([ 50: ...existingEnabledServers, 51: ...projectConfig.enabledMcpjsonServers, 52: ]), 53: ] 54: fieldsToRemove.push('enabledMcpjsonServers') 55: } 56: if (hasDisabledServers && projectConfig.disabledMcpjsonServers) { 57: const existingDisabledServers = 58: existingSettings.disabledMcpjsonServers || [] 59: updates.disabledMcpjsonServers = [ 60: ...new Set([ 61: ...existingDisabledServers, 62: ...projectConfig.disabledMcpjsonServers, 63: ]), 64: ] 65: fieldsToRemove.push('disabledMcpjsonServers') 66: } 67: if (Object.keys(updates).length > 0) { 68: updateSettingsForSource('localSettings', updates) 69: } 70: if ( 71: fieldsToRemove.includes('enableAllProjectMcpServers') || 72: fieldsToRemove.includes('enabledMcpjsonServers') || 73: fieldsToRemove.includes('disabledMcpjsonServers') 74: ) { 75: saveCurrentProjectConfig(current => { 76: const { 77: enableAllProjectMcpServers: _enableAll, 78: enabledMcpjsonServers: _enabledServers, 79: disabledMcpjsonServers: _disabledServers, 80: ...configWithoutFields 81: } = current 82: return configWithoutFields 83: }) 84: } 85: logEvent('tengu_migrate_mcp_approval_fields_success', { 86: migratedCount: fieldsToRemove.length, 87: }) 88: } catch (e: unknown) { 89: logError(e) 90: logEvent('tengu_migrate_mcp_approval_fields_error', {}) 91: } 92: }

File: src/migrations/migrateFennecToOpus.ts

typescript 1: import { 2: getSettingsForSource, 3: updateSettingsForSource, 4: } from '../utils/settings/settings.js' 5: export function migrateFennecToOpus(): void { 6: if (process.env.USER_TYPE !== 'ant') { 7: return 8: } 9: const settings = getSettingsForSource('userSettings') 10: const model = settings?.model 11: if (typeof model === 'string') { 12: if (model.startsWith('fennec-latest[1m]')) { 13: updateSettingsForSource('userSettings', { 14: model: 'opus[1m]', 15: }) 16: } else if (model.startsWith('fennec-latest')) { 17: updateSettingsForSource('userSettings', { 18: model: 'opus', 19: }) 20: } else if ( 21: model.startsWith('fennec-fast-latest') || 22: model.startsWith('opus-4-5-fast') 23: ) { 24: updateSettingsForSource('userSettings', { 25: model: 'opus[1m]', 26: fastMode: true, 27: }) 28: } 29: } 30: }

File: src/migrations/migrateLegacyOpusToCurrent.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from '../services/analytics/index.js' 5: import { saveGlobalConfig } from '../utils/config.js' 6: import { isLegacyModelRemapEnabled } from '../utils/model/model.js' 7: import { getAPIProvider } from '../utils/model/providers.js' 8: import { 9: getSettingsForSource, 10: updateSettingsForSource, 11: } from '../utils/settings/settings.js' 12: export function migrateLegacyOpusToCurrent(): void { 13: if (getAPIProvider() !== 'firstParty') { 14: return 15: } 16: if (!isLegacyModelRemapEnabled()) { 17: return 18: } 19: const model = getSettingsForSource('userSettings')?.model 20: if ( 21: model !== 'claude-opus-4-20250514' && 22: model !== 'claude-opus-4-1-20250805' && 23: model !== 'claude-opus-4-0' && 24: model !== 'claude-opus-4-1' 25: ) { 26: return 27: } 28: updateSettingsForSource('userSettings', { model: 'opus' }) 29: saveGlobalConfig(current => ({ 30: ...current, 31: legacyOpusMigrationTimestamp: Date.now(), 32: })) 33: logEvent('tengu_legacy_opus_migration', { 34: from_model: 35: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 36: }) 37: }

File: src/migrations/migrateOpusToOpus1m.ts

typescript 1: import { logEvent } from '../services/analytics/index.js' 2: import { 3: getDefaultMainLoopModelSetting, 4: isOpus1mMergeEnabled, 5: parseUserSpecifiedModel, 6: } from '../utils/model/model.js' 7: import { 8: getSettingsForSource, 9: updateSettingsForSource, 10: } from '../utils/settings/settings.js' 11: export function migrateOpusToOpus1m(): void { 12: if (!isOpus1mMergeEnabled()) { 13: return 14: } 15: const model = getSettingsForSource('userSettings')?.model 16: if (model !== 'opus') { 17: return 18: } 19: const migrated = 'opus[1m]' 20: const modelToSet = 21: parseUserSpecifiedModel(migrated) === 22: parseUserSpecifiedModel(getDefaultMainLoopModelSetting()) 23: ? undefined 24: : migrated 25: updateSettingsForSource('userSettings', { model: modelToSet }) 26: logEvent('tengu_opus_to_opus1m_migration', {}) 27: }

File: src/migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.ts

typescript 1: import { saveGlobalConfig } from '../utils/config.js' 2: export function migrateReplBridgeEnabledToRemoteControlAtStartup(): void { 3: saveGlobalConfig(prev => { 4: const oldValue = (prev as Record<string, unknown>)['replBridgeEnabled'] 5: if (oldValue === undefined) return prev 6: if (prev.remoteControlAtStartup !== undefined) return prev 7: const next = { ...prev, remoteControlAtStartup: Boolean(oldValue) } 8: delete (next as Record<string, unknown>)['replBridgeEnabled'] 9: return next 10: }) 11: }

File: src/migrations/migrateSonnet1mToSonnet45.ts

typescript 1: import { 2: getMainLoopModelOverride, 3: setMainLoopModelOverride, 4: } from '../bootstrap/state.js' 5: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 6: import { 7: getSettingsForSource, 8: updateSettingsForSource, 9: } from '../utils/settings/settings.js' 10: export function migrateSonnet1mToSonnet45(): void { 11: const config = getGlobalConfig() 12: if (config.sonnet1m45MigrationComplete) { 13: return 14: } 15: const model = getSettingsForSource('userSettings')?.model 16: if (model === 'sonnet[1m]') { 17: updateSettingsForSource('userSettings', { 18: model: 'sonnet-4-5-20250929[1m]', 19: }) 20: } 21: const override = getMainLoopModelOverride() 22: if (override === 'sonnet[1m]') { 23: setMainLoopModelOverride('sonnet-4-5-20250929[1m]') 24: } 25: saveGlobalConfig(current => ({ 26: ...current, 27: sonnet1m45MigrationComplete: true, 28: })) 29: }

File: src/migrations/migrateSonnet45ToSonnet46.ts

typescript 1: import { 2: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3: logEvent, 4: } from '../services/analytics/index.js' 5: import { 6: isMaxSubscriber, 7: isProSubscriber, 8: isTeamPremiumSubscriber, 9: } from '../utils/auth.js' 10: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 11: import { getAPIProvider } from '../utils/model/providers.js' 12: import { 13: getSettingsForSource, 14: updateSettingsForSource, 15: } from '../utils/settings/settings.js' 16: export function migrateSonnet45ToSonnet46(): void { 17: if (getAPIProvider() !== 'firstParty') { 18: return 19: } 20: if (!isProSubscriber() && !isMaxSubscriber() && !isTeamPremiumSubscriber()) { 21: return 22: } 23: const model = getSettingsForSource('userSettings')?.model 24: if ( 25: model !== 'claude-sonnet-4-5-20250929' && 26: model !== 'claude-sonnet-4-5-20250929[1m]' && 27: model !== 'sonnet-4-5-20250929' && 28: model !== 'sonnet-4-5-20250929[1m]' 29: ) { 30: return 31: } 32: const has1m = model.endsWith('[1m]') 33: updateSettingsForSource('userSettings', { 34: model: has1m ? 'sonnet[1m]' : 'sonnet', 35: }) 36: const config = getGlobalConfig() 37: if (config.numStartups > 1) { 38: saveGlobalConfig(current => ({ 39: ...current, 40: sonnet45To46MigrationTimestamp: Date.now(), 41: })) 42: } 43: logEvent('tengu_sonnet45_to_46_migration', { 44: from_model: 45: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 46: has_1m: has1m, 47: }) 48: }

File: src/migrations/resetAutoModeOptInForDefaultOffer.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { logEvent } from 'src/services/analytics/index.js' 3: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 4: import { logError } from '../utils/log.js' 5: import { getAutoModeEnabledState } from '../utils/permissions/permissionSetup.js' 6: import { 7: getSettingsForSource, 8: updateSettingsForSource, 9: } from '../utils/settings/settings.js' 10: export function resetAutoModeOptInForDefaultOffer(): void { 11: if (feature('TRANSCRIPT_CLASSIFIER')) { 12: const config = getGlobalConfig() 13: if (config.hasResetAutoModeOptInForDefaultOffer) return 14: if (getAutoModeEnabledState() !== 'enabled') return 15: try { 16: const user = getSettingsForSource('userSettings') 17: if ( 18: user?.skipAutoPermissionPrompt && 19: user?.permissions?.defaultMode !== 'auto' 20: ) { 21: updateSettingsForSource('userSettings', { 22: skipAutoPermissionPrompt: undefined, 23: }) 24: logEvent('tengu_migrate_reset_auto_opt_in_for_default_offer', {}) 25: } 26: saveGlobalConfig(c => { 27: if (c.hasResetAutoModeOptInForDefaultOffer) return c 28: return { ...c, hasResetAutoModeOptInForDefaultOffer: true } 29: }) 30: } catch (error) { 31: logError(new Error(`Failed to reset auto mode opt-in: ${error}`)) 32: } 33: } 34: }

File: src/migrations/resetProToOpusDefault.ts

typescript 1: import { logEvent } from 'src/services/analytics/index.js' 2: import { isProSubscriber } from '../utils/auth.js' 3: import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' 4: import { getAPIProvider } from '../utils/model/providers.js' 5: import { getSettings_DEPRECATED } from '../utils/settings/settings.js' 6: export function resetProToOpusDefault(): void { 7: const config = getGlobalConfig() 8: if (config.opusProMigrationComplete) { 9: return 10: } 11: const apiProvider = getAPIProvider() 12: if (apiProvider !== 'firstParty' || !isProSubscriber()) { 13: saveGlobalConfig(current => ({ 14: ...current, 15: opusProMigrationComplete: true, 16: })) 17: logEvent('tengu_reset_pro_to_opus_default', { skipped: true }) 18: return 19: } 20: const settings = getSettings_DEPRECATED() 21: if (settings?.model === undefined) { 22: const opusProMigrationTimestamp = Date.now() 23: saveGlobalConfig(current => ({ 24: ...current, 25: opusProMigrationComplete: true, 26: opusProMigrationTimestamp, 27: })) 28: logEvent('tengu_reset_pro_to_opus_default', { 29: skipped: false, 30: had_custom_model: false, 31: }) 32: } else { 33: saveGlobalConfig(current => ({ 34: ...current, 35: opusProMigrationComplete: true, 36: })) 37: logEvent('tengu_reset_pro_to_opus_default', { 38: skipped: false, 39: had_custom_model: true, 40: }) 41: } 42: }

File: src/moreright/useMoreRight.tsx

typescript 1: type M = any; 2: export function useMoreRight(_args: { 3: enabled: boolean; 4: setMessages: (action: M[] | ((prev: M[]) => M[])) => void; 5: inputValue: string; 6: setInputValue: (s: string) => void; 7: setToolJSX: (args: M) => void; 8: }): { 9: onBeforeQuery: (input: string, all: M[], n: number) => Promise<boolean>; 10: onTurnComplete: (all: M[], aborted: boolean) => Promise<void>; 11: render: () => null; 12: } { 13: return { 14: onBeforeQuery: async () => true, 15: onTurnComplete: async () => {}, 16: render: () => null 17: }; 18: }

File: src/native-ts/color-diff/index.ts

typescript 1: import { diffArrays } from 'diff' 2: import type * as hljsNamespace from 'highlight.js' 3: import { basename, extname } from 'path' 4: type HLJSApi = typeof hljsNamespace 5: let cachedHljs: HLJSApi | null = null 6: function hljs(): HLJSApi { 7: if (cachedHljs) return cachedHljs 8: const mod = require('highlight.js') 9: cachedHljs = 'default' in mod && mod.default ? mod.default : mod 10: return cachedHljs! 11: } 12: import { stringWidth } from '../../ink/stringWidth.js' 13: import { logError } from '../../utils/log.js' 14: export type Hunk = { 15: oldStart: number 16: oldLines: number 17: newStart: number 18: newLines: number 19: lines: string[] 20: } 21: export type SyntaxTheme = { 22: theme: string 23: source: string | null 24: } 25: export type NativeModule = { 26: ColorDiff: typeof ColorDiff 27: ColorFile: typeof ColorFile 28: getSyntaxTheme: (themeName: string) => SyntaxTheme 29: } 30: type Color = { r: number; g: number; b: number; a: number } 31: type Style = { foreground: Color; background: Color } 32: type Block = [Style, string] 33: type ColorMode = 'truecolor' | 'color256' | 'ansi' 34: const RESET = '\x1b[0m' 35: const DIM = '\x1b[2m' 36: const UNDIM = '\x1b[22m' 37: function rgb(r: number, g: number, b: number): Color { 38: return { r, g, b, a: 255 } 39: } 40: function ansiIdx(index: number): Color { 41: return { r: index, g: 0, b: 0, a: 0 } 42: } 43: const DEFAULT_BG: Color = { r: 0, g: 0, b: 0, a: 1 } 44: function detectColorMode(theme: string): ColorMode { 45: if (theme.includes('ansi')) return 'ansi' 46: const ct = process.env.COLORTERM ?? '' 47: return ct === 'truecolor' || ct === '24bit' ? 'truecolor' : 'color256' 48: } 49: const CUBE_LEVELS = [0, 95, 135, 175, 215, 255] 50: function ansi256FromRgb(r: number, g: number, b: number): number { 51: const q = (c: number) => 52: c < 48 ? 0 : c < 115 ? 1 : c < 155 ? 2 : c < 195 ? 3 : c < 235 ? 4 : 5 53: const qr = q(r) 54: const qg = q(g) 55: const qb = q(b) 56: const cubeIdx = 16 + 36 * qr + 6 * qg + qb 57: const grey = Math.round((r + g + b) / 3) 58: if (grey < 5) return 16 59: if (grey > 244 && qr === qg && qg === qb) return cubeIdx 60: const greyLevel = Math.max(0, Math.min(23, Math.round((grey - 8) / 10))) 61: const greyIdx = 232 + greyLevel 62: const greyRgb = 8 + greyLevel * 10 63: const cr = CUBE_LEVELS[qr]! 64: const cg = CUBE_LEVELS[qg]! 65: const cb = CUBE_LEVELS[qb]! 66: const dCube = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2 67: const dGrey = (r - greyRgb) ** 2 + (g - greyRgb) ** 2 + (b - greyRgb) ** 2 68: return dGrey < dCube ? greyIdx : cubeIdx 69: } 70: function colorToEscape(c: Color, fg: boolean, mode: ColorMode): string { 71: if (c.a === 0) { 72: const idx = c.r 73: if (idx < 8) return `\x1b[${(fg ? 30 : 40) + idx}m` 74: if (idx < 16) return `\x1b[${(fg ? 90 : 100) + (idx - 8)}m` 75: return `\x1b[${fg ? 38 : 48};5;${idx}m` 76: } 77: if (c.a === 1) return fg ? '\x1b[39m' : '\x1b[49m' 78: const codeType = fg ? 38 : 48 79: if (mode === 'truecolor') { 80: return `\x1b[${codeType};2;${c.r};${c.g};${c.b}m` 81: } 82: return `\x1b[${codeType};5;${ansi256FromRgb(c.r, c.g, c.b)}m` 83: } 84: function asTerminalEscaped( 85: blocks: readonly Block[], 86: mode: ColorMode, 87: skipBackground: boolean, 88: dim: boolean, 89: ): string { 90: let out = dim ? RESET + DIM : RESET 91: for (const [style, text] of blocks) { 92: out += colorToEscape(style.foreground, true, mode) 93: if (!skipBackground) { 94: out += colorToEscape(style.background, false, mode) 95: } 96: out += text 97: } 98: return out + RESET 99: } 100: type Marker = '+' | '-' | ' ' 101: type Theme = { 102: addLine: Color 103: addWord: Color 104: addDecoration: Color 105: deleteLine: Color 106: deleteWord: Color 107: deleteDecoration: Color 108: foreground: Color 109: background: Color 110: scopes: Record<string, Color> 111: } 112: function defaultSyntaxThemeName(themeName: string): string { 113: if (themeName.includes('ansi')) return 'ansi' 114: if (themeName.includes('dark')) return 'Monokai Extended' 115: return 'GitHub' 116: } 117: const MONOKAI_SCOPES: Record<string, Color> = { 118: keyword: rgb(249, 38, 114), 119: _storage: rgb(102, 217, 239), 120: built_in: rgb(166, 226, 46), 121: type: rgb(166, 226, 46), 122: literal: rgb(190, 132, 255), 123: number: rgb(190, 132, 255), 124: string: rgb(230, 219, 116), 125: title: rgb(166, 226, 46), 126: 'title.function': rgb(166, 226, 46), 127: 'title.class': rgb(166, 226, 46), 128: 'title.class.inherited': rgb(166, 226, 46), 129: params: rgb(253, 151, 31), 130: comment: rgb(117, 113, 94), 131: meta: rgb(117, 113, 94), 132: attr: rgb(166, 226, 46), 133: attribute: rgb(166, 226, 46), 134: variable: rgb(255, 255, 255), 135: 'variable.language': rgb(255, 255, 255), 136: property: rgb(255, 255, 255), 137: operator: rgb(249, 38, 114), 138: punctuation: rgb(248, 248, 242), 139: symbol: rgb(190, 132, 255), 140: regexp: rgb(230, 219, 116), 141: subst: rgb(248, 248, 242), 142: } 143: const GITHUB_SCOPES: Record<string, Color> = { 144: keyword: rgb(167, 29, 93), 145: _storage: rgb(167, 29, 93), 146: built_in: rgb(0, 134, 179), 147: type: rgb(0, 134, 179), 148: literal: rgb(0, 134, 179), 149: number: rgb(0, 134, 179), 150: string: rgb(24, 54, 145), 151: title: rgb(121, 93, 163), 152: 'title.function': rgb(121, 93, 163), 153: 'title.class': rgb(0, 0, 0), 154: 'title.class.inherited': rgb(0, 0, 0), 155: params: rgb(0, 134, 179), 156: comment: rgb(150, 152, 150), 157: meta: rgb(150, 152, 150), 158: attr: rgb(0, 134, 179), 159: attribute: rgb(0, 134, 179), 160: variable: rgb(0, 134, 179), 161: 'variable.language': rgb(0, 134, 179), 162: property: rgb(0, 134, 179), 163: operator: rgb(167, 29, 93), 164: punctuation: rgb(51, 51, 51), 165: symbol: rgb(0, 134, 179), 166: regexp: rgb(24, 54, 145), 167: subst: rgb(51, 51, 51), 168: } 169: const STORAGE_KEYWORDS = new Set([ 170: 'const', 171: 'let', 172: 'var', 173: 'function', 174: 'class', 175: 'type', 176: 'interface', 177: 'enum', 178: 'namespace', 179: 'module', 180: 'def', 181: 'fn', 182: 'func', 183: 'struct', 184: 'trait', 185: 'impl', 186: ]) 187: const ANSI_SCOPES: Record<string, Color> = { 188: keyword: ansiIdx(13), 189: _storage: ansiIdx(14), 190: built_in: ansiIdx(14), 191: type: ansiIdx(14), 192: literal: ansiIdx(12), 193: number: ansiIdx(12), 194: string: ansiIdx(10), 195: title: ansiIdx(11), 196: 'title.function': ansiIdx(11), 197: 'title.class': ansiIdx(11), 198: comment: ansiIdx(8), 199: meta: ansiIdx(8), 200: } 201: function buildTheme(themeName: string, mode: ColorMode): Theme { 202: const isDark = themeName.includes('dark') 203: const isAnsi = themeName.includes('ansi') 204: const isDaltonized = themeName.includes('daltonized') 205: const tc = mode === 'truecolor' 206: if (isAnsi) { 207: return { 208: addLine: DEFAULT_BG, 209: addWord: DEFAULT_BG, 210: addDecoration: ansiIdx(10), 211: deleteLine: DEFAULT_BG, 212: deleteWord: DEFAULT_BG, 213: deleteDecoration: ansiIdx(9), 214: foreground: ansiIdx(7), 215: background: DEFAULT_BG, 216: scopes: ANSI_SCOPES, 217: } 218: } 219: if (isDark) { 220: const fg = rgb(248, 248, 242) 221: const deleteLine = rgb(61, 1, 0) 222: const deleteWord = rgb(92, 2, 0) 223: const deleteDecoration = rgb(220, 90, 90) 224: if (isDaltonized) { 225: return { 226: addLine: tc ? rgb(0, 27, 41) : ansiIdx(17), 227: addWord: tc ? rgb(0, 48, 71) : ansiIdx(24), 228: addDecoration: rgb(81, 160, 200), 229: deleteLine, 230: deleteWord, 231: deleteDecoration, 232: foreground: fg, 233: background: DEFAULT_BG, 234: scopes: MONOKAI_SCOPES, 235: } 236: } 237: return { 238: addLine: tc ? rgb(2, 40, 0) : ansiIdx(22), 239: addWord: tc ? rgb(4, 71, 0) : ansiIdx(28), 240: addDecoration: rgb(80, 200, 80), 241: deleteLine, 242: deleteWord, 243: deleteDecoration, 244: foreground: fg, 245: background: DEFAULT_BG, 246: scopes: MONOKAI_SCOPES, 247: } 248: } 249: const fg = rgb(51, 51, 51) 250: const deleteLine = rgb(255, 220, 220) 251: const deleteWord = rgb(255, 199, 199) 252: const deleteDecoration = rgb(207, 34, 46) 253: if (isDaltonized) { 254: return { 255: addLine: rgb(219, 237, 255), 256: addWord: rgb(179, 217, 255), 257: addDecoration: rgb(36, 87, 138), 258: deleteLine, 259: deleteWord, 260: deleteDecoration, 261: foreground: fg, 262: background: DEFAULT_BG, 263: scopes: GITHUB_SCOPES, 264: } 265: } 266: return { 267: addLine: rgb(220, 255, 220), 268: addWord: rgb(178, 255, 178), 269: addDecoration: rgb(36, 138, 61), 270: deleteLine, 271: deleteWord, 272: deleteDecoration, 273: foreground: fg, 274: background: DEFAULT_BG, 275: scopes: GITHUB_SCOPES, 276: } 277: } 278: function defaultStyle(theme: Theme): Style { 279: return { foreground: theme.foreground, background: theme.background } 280: } 281: function lineBackground(marker: Marker, theme: Theme): Color { 282: switch (marker) { 283: case '+': 284: return theme.addLine 285: case '-': 286: return theme.deleteLine 287: case ' ': 288: return theme.background 289: } 290: } 291: function wordBackground(marker: Marker, theme: Theme): Color { 292: switch (marker) { 293: case '+': 294: return theme.addWord 295: case '-': 296: return theme.deleteWord 297: case ' ': 298: return theme.background 299: } 300: } 301: function decorationColor(marker: Marker, theme: Theme): Color { 302: switch (marker) { 303: case '+': 304: return theme.addDecoration 305: case '-': 306: return theme.deleteDecoration 307: case ' ': 308: return theme.foreground 309: } 310: } 311: type HljsNode = { 312: scope?: string 313: kind?: string 314: children: (HljsNode | string)[] 315: } 316: const FILENAME_LANGS: Record<string, string> = { 317: Dockerfile: 'dockerfile', 318: Makefile: 'makefile', 319: Rakefile: 'ruby', 320: Gemfile: 'ruby', 321: CMakeLists: 'cmake', 322: } 323: function detectLanguage( 324: filePath: string, 325: firstLine: string | null, 326: ): string | null { 327: const base = basename(filePath) 328: const ext = extname(filePath).slice(1) 329: const stem = base.split('.')[0] ?? '' 330: const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem] 331: if (byName && hljs().getLanguage(byName)) return byName 332: if (ext) { 333: const lang = hljs().getLanguage(ext) 334: if (lang) return ext 335: } 336: // Shebang / first-line detection (strip UTF-8 BOM) 337: if (firstLine) { 338: const line = firstLine.startsWith('\ufeff') ? firstLine.slice(1) : firstLine 339: if (line.startsWith('#!')) { 340: if (line.includes('bash') || line.includes('/sh')) return 'bash' 341: if (line.includes('python')) return 'python' 342: if (line.includes('node')) return 'javascript' 343: if (line.includes('ruby')) return 'ruby' 344: if (line.includes('perl')) return 'perl' 345: } 346: if (line.startsWith('<?php')) return 'php' 347: if (line.startsWith('<?xml')) return 'xml' 348: } 349: return null 350: } 351: function scopeColor( 352: scope: string | undefined, 353: text: string, 354: theme: Theme, 355: ): Color { 356: if (!scope) return theme.foreground 357: if (scope === 'keyword' && STORAGE_KEYWORDS.has(text.trim())) { 358: return theme.scopes['_storage'] ?? theme.foreground 359: } 360: return ( 361: theme.scopes[scope] ?? 362: theme.scopes[scope.split('.')[0]!] ?? 363: theme.foreground 364: ) 365: } 366: function flattenHljs( 367: node: HljsNode | string, 368: theme: Theme, 369: parentScope: string | undefined, 370: out: Block[], 371: ): void { 372: if (typeof node === 'string') { 373: const fg = scopeColor(parentScope, node, theme) 374: out.push([{ foreground: fg, background: theme.background }, node]) 375: return 376: } 377: const scope = node.scope ?? node.kind ?? parentScope 378: for (const child of node.children) { 379: flattenHljs(child, theme, scope, out) 380: } 381: } 382: function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } { 383: return ( 384: typeof emitter === 'object' && 385: emitter !== null && 386: 'rootNode' in emitter && 387: typeof emitter.rootNode === 'object' && 388: emitter.rootNode !== null && 389: 'children' in emitter.rootNode 390: ) 391: } 392: let loggedEmitterShapeError = false 393: function highlightLine( 394: state: { lang: string | null; stack: unknown }, 395: line: string, 396: theme: Theme, 397: ): Block[] { 398: const code = line + '\n' 399: if (!state.lang) { 400: return [[defaultStyle(theme), code]] 401: } 402: let result 403: try { 404: result = hljs().highlight(code, { 405: language: state.lang, 406: ignoreIllegals: true, 407: }) 408: } catch { 409: return [[defaultStyle(theme), code]] 410: } 411: if (!hasRootNode(result.emitter)) { 412: if (!loggedEmitterShapeError) { 413: loggedEmitterShapeError = true 414: logError( 415: new Error( 416: `color-diff: hljs emitter shape mismatch (keys: ${Object.keys(result.emitter).join(',')}). Syntax highlighting disabled.`, 417: ), 418: ) 419: } 420: return [[defaultStyle(theme), code]] 421: } 422: const blocks: Block[] = [] 423: flattenHljs(result.emitter.rootNode, theme, undefined, blocks) 424: return blocks 425: } 426: type Range = { start: number; end: number } 427: const CHANGE_THRESHOLD = 0.4 428: function tokenize(text: string): string[] { 429: const tokens: string[] = [] 430: let i = 0 431: while (i < text.length) { 432: const ch = text[i]! 433: if (/[\p{L}\p{N}_]/u.test(ch)) { 434: let j = i + 1 435: while (j < text.length && /[\p{L}\p{N}_]/u.test(text[j]!)) j++ 436: tokens.push(text.slice(i, j)) 437: i = j 438: } else if (/\s/.test(ch)) { 439: let j = i + 1 440: while (j < text.length && /\s/.test(text[j]!)) j++ 441: tokens.push(text.slice(i, j)) 442: i = j 443: } else { 444: const cp = text.codePointAt(i)! 445: const len = cp > 0xffff ? 2 : 1 446: tokens.push(text.slice(i, i + len)) 447: i += len 448: } 449: } 450: return tokens 451: } 452: function findAdjacentPairs(markers: Marker[]): [number, number][] { 453: const pairs: [number, number][] = [] 454: let i = 0 455: while (i < markers.length) { 456: if (markers[i] === '-') { 457: const delStart = i 458: let delEnd = i 459: while (delEnd < markers.length && markers[delEnd] === '-') delEnd++ 460: let addEnd = delEnd 461: while (addEnd < markers.length && markers[addEnd] === '+') addEnd++ 462: const delCount = delEnd - delStart 463: const addCount = addEnd - delEnd 464: if (delCount > 0 && addCount > 0) { 465: const n = Math.min(delCount, addCount) 466: for (let k = 0; k < n; k++) { 467: pairs.push([delStart + k, delEnd + k]) 468: } 469: i = addEnd 470: } else { 471: i = delEnd 472: } 473: } else { 474: i++ 475: } 476: } 477: return pairs 478: } 479: function wordDiffStrings(oldStr: string, newStr: string): [Range[], Range[]] { 480: const oldTokens = tokenize(oldStr) 481: const newTokens = tokenize(newStr) 482: const ops = diffArrays(oldTokens, newTokens) 483: const totalLen = oldStr.length + newStr.length 484: let changedLen = 0 485: const oldRanges: Range[] = [] 486: const newRanges: Range[] = [] 487: let oldOff = 0 488: let newOff = 0 489: for (const op of ops) { 490: const len = op.value.reduce((s, t) => s + t.length, 0) 491: if (op.removed) { 492: changedLen += len 493: oldRanges.push({ start: oldOff, end: oldOff + len }) 494: oldOff += len 495: } else if (op.added) { 496: changedLen += len 497: newRanges.push({ start: newOff, end: newOff + len }) 498: newOff += len 499: } else { 500: oldOff += len 501: newOff += len 502: } 503: } 504: if (totalLen > 0 && changedLen / totalLen > CHANGE_THRESHOLD) { 505: return [[], []] 506: } 507: return [oldRanges, newRanges] 508: } 509: type Highlight = { 510: marker: Marker | null 511: lineNumber: number 512: lines: Block[][] 513: } 514: function removeNewlines(h: Highlight): void { 515: h.lines = h.lines.map(line => 516: line.flatMap(([style, text]) => 517: text 518: .split('\n') 519: .filter(p => p.length > 0) 520: .map((p): Block => [style, p]), 521: ), 522: ) 523: } 524: function charWidth(ch: string): number { 525: return stringWidth(ch) 526: } 527: function wrapText(h: Highlight, width: number, theme: Theme): void { 528: const newLines: Block[][] = [] 529: for (const line of h.lines) { 530: const queue: Block[] = line.slice() 531: let cur: Block[] = [] 532: let curW = 0 533: while (queue.length > 0) { 534: const [style, text] = queue.shift()! 535: const tw = stringWidth(text) 536: if (curW + tw <= width) { 537: cur.push([style, text]) 538: curW += tw 539: } else { 540: const remaining = width - curW 541: let bytePos = 0 542: let accW = 0 543: for (const ch of text) { 544: const cw = charWidth(ch) 545: if (accW + cw > remaining) break 546: accW += cw 547: bytePos += ch.length 548: } 549: if (bytePos === 0) { 550: if (curW === 0) { 551: const firstCp = text.codePointAt(0)! 552: bytePos = firstCp > 0xffff ? 2 : 1 553: } else { 554: newLines.push(cur) 555: queue.unshift([style, text]) 556: cur = [] 557: curW = 0 558: continue 559: } 560: } 561: cur.push([style, text.slice(0, bytePos)]) 562: newLines.push(cur) 563: queue.unshift([style, text.slice(bytePos)]) 564: cur = [] 565: curW = 0 566: } 567: } 568: newLines.push(cur) 569: } 570: h.lines = newLines 571: if (h.marker && h.marker !== ' ') { 572: const bg = lineBackground(h.marker, theme) 573: const padStyle: Style = { foreground: theme.foreground, background: bg } 574: for (const line of h.lines) { 575: const curW = line.reduce((s, [, t]) => s + stringWidth(t), 0) 576: if (curW < width) { 577: line.push([padStyle, ' '.repeat(width - curW)]) 578: } 579: } 580: } 581: } 582: function addLineNumber( 583: h: Highlight, 584: theme: Theme, 585: maxDigits: number, 586: fullDim: boolean, 587: ): void { 588: const style: Style = { 589: foreground: h.marker ? decorationColor(h.marker, theme) : theme.foreground, 590: background: h.marker ? lineBackground(h.marker, theme) : theme.background, 591: } 592: const shouldDim = h.marker === null || h.marker === ' ' 593: for (let i = 0; i < h.lines.length; i++) { 594: const prefix = 595: i === 0 596: ? ` ${String(h.lineNumber).padStart(maxDigits)} ` 597: : ' '.repeat(maxDigits + 2) 598: const wrapped = shouldDim && !fullDim ? `${DIM}${prefix}${UNDIM}` : prefix 599: h.lines[i]!.unshift([style, wrapped]) 600: } 601: } 602: function addMarker(h: Highlight, theme: Theme): void { 603: if (!h.marker) return 604: const style: Style = { 605: foreground: decorationColor(h.marker, theme), 606: background: lineBackground(h.marker, theme), 607: } 608: for (const line of h.lines) { 609: line.unshift([style, h.marker]) 610: } 611: } 612: function dimContent(h: Highlight): void { 613: for (const line of h.lines) { 614: if (line.length > 0) { 615: line[0]![1] = DIM + line[0]![1] 616: const last = line.length - 1 617: line[last]![1] = line[last]![1] + UNDIM 618: } 619: } 620: } 621: function applyBackground(h: Highlight, theme: Theme, ranges: Range[]): void { 622: if (!h.marker) return 623: const lineBg = lineBackground(h.marker, theme) 624: const wordBg = wordBackground(h.marker, theme) 625: let rangeIdx = 0 626: let byteOff = 0 627: for (let li = 0; li < h.lines.length; li++) { 628: const newLine: Block[] = [] 629: for (const [style, text] of h.lines[li]!) { 630: const textStart = byteOff 631: const textEnd = byteOff + text.length 632: while (rangeIdx < ranges.length && ranges[rangeIdx]!.end <= textStart) { 633: rangeIdx++ 634: } 635: if (rangeIdx >= ranges.length) { 636: newLine.push([{ ...style, background: lineBg }, text]) 637: byteOff = textEnd 638: continue 639: } 640: let remaining = text 641: let pos = textStart 642: while (remaining.length > 0 && rangeIdx < ranges.length) { 643: const r = ranges[rangeIdx]! 644: const inRange = pos >= r.start && pos < r.end 645: let next: number 646: if (inRange) { 647: next = Math.min(r.end, textEnd) 648: } else if (r.start > pos && r.start < textEnd) { 649: next = r.start 650: } else { 651: next = textEnd 652: } 653: const segLen = next - pos 654: const seg = remaining.slice(0, segLen) 655: newLine.push([{ ...style, background: inRange ? wordBg : lineBg }, seg]) 656: remaining = remaining.slice(segLen) 657: pos = next 658: if (pos >= r.end) rangeIdx++ 659: } 660: if (remaining.length > 0) { 661: newLine.push([{ ...style, background: lineBg }, remaining]) 662: } 663: byteOff = textEnd 664: } 665: h.lines[li] = newLine 666: } 667: } 668: function intoLines( 669: h: Highlight, 670: dim: boolean, 671: skipBg: boolean, 672: mode: ColorMode, 673: ): string[] { 674: return h.lines.map(line => asTerminalEscaped(line, mode, skipBg, dim)) 675: } 676: function maxLineNumber(hunk: Hunk): number { 677: const oldEnd = Math.max(0, hunk.oldStart + hunk.oldLines - 1) 678: const newEnd = Math.max(0, hunk.newStart + hunk.newLines - 1) 679: return Math.max(oldEnd, newEnd) 680: } 681: function parseMarker(s: string): Marker { 682: return s === '+' || s === '-' ? s : ' ' 683: } 684: export class ColorDiff { 685: private hunk: Hunk 686: private filePath: string 687: private firstLine: string | null 688: private prefixContent: string | null 689: constructor( 690: hunk: Hunk, 691: firstLine: string | null, 692: filePath: string, 693: prefixContent?: string | null, 694: ) { 695: this.hunk = hunk 696: this.filePath = filePath 697: this.firstLine = firstLine 698: this.prefixContent = prefixContent ?? null 699: } 700: render(themeName: string, width: number, dim: boolean): string[] | null { 701: const mode = detectColorMode(themeName) 702: const theme = buildTheme(themeName, mode) 703: const lang = detectLanguage(this.filePath, this.firstLine) 704: const hlState = { lang, stack: null } 705: void this.prefixContent 706: const maxDigits = String(maxLineNumber(this.hunk)).length 707: let oldLine = this.hunk.oldStart 708: let newLine = this.hunk.newStart 709: const effectiveWidth = Math.max(1, width - maxDigits - 2 - 1) 710: type Entry = { lineNumber: number; marker: Marker; code: string } 711: const entries: Entry[] = this.hunk.lines.map(rawLine => { 712: const marker = parseMarker(rawLine.slice(0, 1)) 713: const code = rawLine.slice(1) 714: let lineNumber: number 715: switch (marker) { 716: case '+': 717: lineNumber = newLine++ 718: break 719: case '-': 720: lineNumber = oldLine++ 721: break 722: case ' ': 723: lineNumber = newLine 724: oldLine++ 725: newLine++ 726: break 727: } 728: return { lineNumber, marker, code } 729: }) 730: const ranges: Range[][] = entries.map(() => []) 731: if (!dim) { 732: const markers = entries.map(e => e.marker) 733: for (const [delIdx, addIdx] of findAdjacentPairs(markers)) { 734: const [delR, addR] = wordDiffStrings( 735: entries[delIdx]!.code, 736: entries[addIdx]!.code, 737: ) 738: ranges[delIdx] = delR 739: ranges[addIdx] = addR 740: } 741: } 742: const out: string[] = [] 743: for (let i = 0; i < entries.length; i++) { 744: const { lineNumber, marker, code } = entries[i]! 745: const tokens: Block[] = 746: marker === '-' 747: ? [[defaultStyle(theme), code]] 748: : highlightLine(hlState, code, theme) 749: const h: Highlight = { marker, lineNumber, lines: [tokens] } 750: removeNewlines(h) 751: applyBackground(h, theme, ranges[i]!) 752: wrapText(h, effectiveWidth, theme) 753: if (mode === 'ansi' && marker === '-') { 754: dimContent(h) 755: } 756: addMarker(h, theme) 757: addLineNumber(h, theme, maxDigits, dim) 758: out.push(...intoLines(h, dim, false, mode)) 759: } 760: return out 761: } 762: } 763: export class ColorFile { 764: private code: string 765: private filePath: string 766: constructor(code: string, filePath: string) { 767: this.code = code 768: this.filePath = filePath 769: } 770: render(themeName: string, width: number, dim: boolean): string[] | null { 771: const mode = detectColorMode(themeName) 772: const theme = buildTheme(themeName, mode) 773: const lines = this.code.split('\n') 774: if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop() 775: const firstLine = lines[0] ?? null 776: const lang = detectLanguage(this.filePath, firstLine) 777: const hlState = { lang, stack: null } 778: const maxDigits = String(lines.length).length 779: const effectiveWidth = Math.max(1, width - maxDigits - 2) 780: const out: string[] = [] 781: for (let i = 0; i < lines.length; i++) { 782: const tokens = highlightLine(hlState, lines[i]!, theme) 783: const h: Highlight = { marker: null, lineNumber: i + 1, lines: [tokens] } 784: removeNewlines(h) 785: wrapText(h, effectiveWidth, theme) 786: addLineNumber(h, theme, maxDigits, dim) 787: out.push(...intoLines(h, dim, true, mode)) 788: } 789: return out 790: } 791: } 792: export function getSyntaxTheme(themeName: string): SyntaxTheme { 793: // highlight.js has no bat theme set, so env vars can't select alternate 794: const envTheme = 795: process.env.CLAUDE_CODE_SYNTAX_HIGHLIGHT ?? process.env.BAT_THEME 796: void envTheme 797: return { theme: defaultSyntaxThemeName(themeName), source: null } 798: } 799: let cachedModule: NativeModule | null = null 800: export function getNativeModule(): NativeModule | null { 801: if (cachedModule) return cachedModule 802: cachedModule = { ColorDiff, ColorFile, getSyntaxTheme } 803: return cachedModule 804: } 805: export type { ColorDiff as ColorDiffClass, ColorFile as ColorFileClass } 806: export const __test = { 807: tokenize, 808: findAdjacentPairs, 809: wordDiffStrings, 810: ansi256FromRgb, 811: colorToEscape, 812: detectColorMode, 813: detectLanguage, 814: }

File: src/native-ts/file-index/index.ts

typescript 1: export type SearchResult = { 2: path: string 3: score: number 4: } 5: const SCORE_MATCH = 16 6: const BONUS_BOUNDARY = 8 7: const BONUS_CAMEL = 6 8: const BONUS_CONSECUTIVE = 4 9: const BONUS_FIRST_CHAR = 8 10: const PENALTY_GAP_START = 3 11: const PENALTY_GAP_EXTENSION = 1 12: const TOP_LEVEL_CACHE_LIMIT = 100 13: const MAX_QUERY_LEN = 64 14: const CHUNK_MS = 4 15: const posBuf = new Int32Array(MAX_QUERY_LEN) 16: export class FileIndex { 17: private paths: string[] = [] 18: private lowerPaths: string[] = [] 19: private charBits: Int32Array = new Int32Array(0) 20: private pathLens: Uint16Array = new Uint16Array(0) 21: private topLevelCache: SearchResult[] | null = null 22: private readyCount = 0 23: loadFromFileList(fileList: string[]): void { 24: const seen = new Set<string>() 25: const paths: string[] = [] 26: for (const line of fileList) { 27: if (line.length > 0 && !seen.has(line)) { 28: seen.add(line) 29: paths.push(line) 30: } 31: } 32: this.buildIndex(paths) 33: } 34: loadFromFileListAsync(fileList: string[]): { 35: queryable: Promise<void> 36: done: Promise<void> 37: } { 38: let markQueryable: () => void = () => {} 39: const queryable = new Promise<void>(resolve => { 40: markQueryable = resolve 41: }) 42: const done = this.buildAsync(fileList, markQueryable) 43: return { queryable, done } 44: } 45: private async buildAsync( 46: fileList: string[], 47: markQueryable: () => void, 48: ): Promise<void> { 49: const seen = new Set<string>() 50: const paths: string[] = [] 51: let chunkStart = performance.now() 52: for (let i = 0; i < fileList.length; i++) { 53: const line = fileList[i]! 54: if (line.length > 0 && !seen.has(line)) { 55: seen.add(line) 56: paths.push(line) 57: } 58: if ((i & 0xff) === 0xff && performance.now() - chunkStart > CHUNK_MS) { 59: await yieldToEventLoop() 60: chunkStart = performance.now() 61: } 62: } 63: this.resetArrays(paths) 64: chunkStart = performance.now() 65: let firstChunk = true 66: for (let i = 0; i < paths.length; i++) { 67: this.indexPath(i) 68: if ((i & 0xff) === 0xff && performance.now() - chunkStart > CHUNK_MS) { 69: this.readyCount = i + 1 70: if (firstChunk) { 71: markQueryable() 72: firstChunk = false 73: } 74: await yieldToEventLoop() 75: chunkStart = performance.now() 76: } 77: } 78: this.readyCount = paths.length 79: markQueryable() 80: } 81: private buildIndex(paths: string[]): void { 82: this.resetArrays(paths) 83: for (let i = 0; i < paths.length; i++) { 84: this.indexPath(i) 85: } 86: this.readyCount = paths.length 87: } 88: private resetArrays(paths: string[]): void { 89: const n = paths.length 90: this.paths = paths 91: this.lowerPaths = new Array(n) 92: this.charBits = new Int32Array(n) 93: this.pathLens = new Uint16Array(n) 94: this.readyCount = 0 95: this.topLevelCache = computeTopLevelEntries(paths, TOP_LEVEL_CACHE_LIMIT) 96: } 97: private indexPath(i: number): void { 98: const lp = this.paths[i]!.toLowerCase() 99: this.lowerPaths[i] = lp 100: const len = lp.length 101: this.pathLens[i] = len 102: let bits = 0 103: for (let j = 0; j < len; j++) { 104: const c = lp.charCodeAt(j) 105: if (c >= 97 && c <= 122) bits |= 1 << (c - 97) 106: } 107: this.charBits[i] = bits 108: } 109: search(query: string, limit: number): SearchResult[] { 110: if (limit <= 0) return [] 111: if (query.length === 0) { 112: if (this.topLevelCache) { 113: return this.topLevelCache.slice(0, limit) 114: } 115: return [] 116: } 117: const caseSensitive = query !== query.toLowerCase() 118: const needle = caseSensitive ? query : query.toLowerCase() 119: const nLen = Math.min(needle.length, MAX_QUERY_LEN) 120: const needleChars: string[] = new Array(nLen) 121: let needleBitmap = 0 122: for (let j = 0; j < nLen; j++) { 123: const ch = needle.charAt(j) 124: needleChars[j] = ch 125: const cc = ch.charCodeAt(0) 126: if (cc >= 97 && cc <= 122) needleBitmap |= 1 << (cc - 97) 127: } 128: const scoreCeiling = 129: nLen * (SCORE_MATCH + BONUS_BOUNDARY) + BONUS_FIRST_CHAR + 32 130: const topK: { path: string; fuzzScore: number }[] = [] 131: let threshold = -Infinity 132: const { paths, lowerPaths, charBits, pathLens, readyCount } = this 133: outer: for (let i = 0; i < readyCount; i++) { 134: if ((charBits[i]! & needleBitmap) !== needleBitmap) continue 135: const haystack = caseSensitive ? paths[i]! : lowerPaths[i]! 136: let pos = haystack.indexOf(needleChars[0]!) 137: if (pos === -1) continue 138: posBuf[0] = pos 139: let gapPenalty = 0 140: let consecBonus = 0 141: let prev = pos 142: for (let j = 1; j < nLen; j++) { 143: pos = haystack.indexOf(needleChars[j]!, prev + 1) 144: if (pos === -1) continue outer 145: posBuf[j] = pos 146: const gap = pos - prev - 1 147: if (gap === 0) consecBonus += BONUS_CONSECUTIVE 148: else gapPenalty += PENALTY_GAP_START + gap * PENALTY_GAP_EXTENSION 149: prev = pos 150: } 151: if ( 152: topK.length === limit && 153: scoreCeiling + consecBonus - gapPenalty <= threshold 154: ) { 155: continue 156: } 157: const path = paths[i]! 158: const hLen = pathLens[i]! 159: let score = nLen * SCORE_MATCH + consecBonus - gapPenalty 160: score += scoreBonusAt(path, posBuf[0]!, true) 161: for (let j = 1; j < nLen; j++) { 162: score += scoreBonusAt(path, posBuf[j]!, false) 163: } 164: score += Math.max(0, 32 - (hLen >> 2)) 165: if (topK.length < limit) { 166: topK.push({ path, fuzzScore: score }) 167: if (topK.length === limit) { 168: topK.sort((a, b) => a.fuzzScore - b.fuzzScore) 169: threshold = topK[0]!.fuzzScore 170: } 171: } else if (score > threshold) { 172: let lo = 0 173: let hi = topK.length 174: while (lo < hi) { 175: const mid = (lo + hi) >> 1 176: if (topK[mid]!.fuzzScore < score) lo = mid + 1 177: else hi = mid 178: } 179: topK.splice(lo, 0, { path, fuzzScore: score }) 180: topK.shift() 181: threshold = topK[0]!.fuzzScore 182: } 183: } 184: topK.sort((a, b) => b.fuzzScore - a.fuzzScore) 185: const matchCount = topK.length 186: const denom = Math.max(matchCount, 1) 187: const results: SearchResult[] = new Array(matchCount) 188: for (let i = 0; i < matchCount; i++) { 189: const path = topK[i]!.path 190: const positionScore = i / denom 191: const finalScore = path.includes('test') 192: ? Math.min(positionScore * 1.05, 1.0) 193: : positionScore 194: results[i] = { path, score: finalScore } 195: } 196: return results 197: } 198: } 199: function scoreBonusAt(path: string, pos: number, first: boolean): number { 200: if (pos === 0) return first ? BONUS_FIRST_CHAR : 0 201: const prevCh = path.charCodeAt(pos - 1) 202: if (isBoundary(prevCh)) return BONUS_BOUNDARY 203: if (isLower(prevCh) && isUpper(path.charCodeAt(pos))) return BONUS_CAMEL 204: return 0 205: } 206: function isBoundary(code: number): boolean { 207: return ( 208: code === 47 || 209: code === 92 || 210: code === 45 || 211: code === 95 || 212: code === 46 || 213: code === 32 214: ) 215: } 216: function isLower(code: number): boolean { 217: return code >= 97 && code <= 122 218: } 219: function isUpper(code: number): boolean { 220: return code >= 65 && code <= 90 221: } 222: export function yieldToEventLoop(): Promise<void> { 223: return new Promise(resolve => setImmediate(resolve)) 224: } 225: export { CHUNK_MS } 226: function computeTopLevelEntries( 227: paths: string[], 228: limit: number, 229: ): SearchResult[] { 230: const topLevel = new Set<string>() 231: for (const p of paths) { 232: let end = p.length 233: for (let i = 0; i < p.length; i++) { 234: const c = p.charCodeAt(i) 235: if (c === 47 || c === 92) { 236: end = i 237: break 238: } 239: } 240: const segment = p.slice(0, end) 241: if (segment.length > 0) { 242: topLevel.add(segment) 243: if (topLevel.size >= limit) break 244: } 245: } 246: const sorted = Array.from(topLevel) 247: sorted.sort((a, b) => { 248: const lenDiff = a.length - b.length 249: if (lenDiff !== 0) return lenDiff 250: return a < b ? -1 : a > b ? 1 : 0 251: }) 252: return sorted.slice(0, limit).map(path => ({ path, score: 0.0 })) 253: } 254: export default FileIndex 255: export type { FileIndex as FileIndexType }

File: src/native-ts/yoga-layout/enums.ts

typescript 1: export const Align = { 2: Auto: 0, 3: FlexStart: 1, 4: Center: 2, 5: FlexEnd: 3, 6: Stretch: 4, 7: Baseline: 5, 8: SpaceBetween: 6, 9: SpaceAround: 7, 10: SpaceEvenly: 8, 11: } as const 12: export type Align = (typeof Align)[keyof typeof Align] 13: export const BoxSizing = { 14: BorderBox: 0, 15: ContentBox: 1, 16: } as const 17: export type BoxSizing = (typeof BoxSizing)[keyof typeof BoxSizing] 18: export const Dimension = { 19: Width: 0, 20: Height: 1, 21: } as const 22: export type Dimension = (typeof Dimension)[keyof typeof Dimension] 23: export const Direction = { 24: Inherit: 0, 25: LTR: 1, 26: RTL: 2, 27: } as const 28: export type Direction = (typeof Direction)[keyof typeof Direction] 29: export const Display = { 30: Flex: 0, 31: None: 1, 32: Contents: 2, 33: } as const 34: export type Display = (typeof Display)[keyof typeof Display] 35: export const Edge = { 36: Left: 0, 37: Top: 1, 38: Right: 2, 39: Bottom: 3, 40: Start: 4, 41: End: 5, 42: Horizontal: 6, 43: Vertical: 7, 44: All: 8, 45: } as const 46: export type Edge = (typeof Edge)[keyof typeof Edge] 47: export const Errata = { 48: None: 0, 49: StretchFlexBasis: 1, 50: AbsolutePositionWithoutInsetsExcludesPadding: 2, 51: AbsolutePercentAgainstInnerSize: 4, 52: All: 2147483647, 53: Classic: 2147483646, 54: } as const 55: export type Errata = (typeof Errata)[keyof typeof Errata] 56: export const ExperimentalFeature = { 57: WebFlexBasis: 0, 58: } as const 59: export type ExperimentalFeature = 60: (typeof ExperimentalFeature)[keyof typeof ExperimentalFeature] 61: export const FlexDirection = { 62: Column: 0, 63: ColumnReverse: 1, 64: Row: 2, 65: RowReverse: 3, 66: } as const 67: export type FlexDirection = (typeof FlexDirection)[keyof typeof FlexDirection] 68: export const Gutter = { 69: Column: 0, 70: Row: 1, 71: All: 2, 72: } as const 73: export type Gutter = (typeof Gutter)[keyof typeof Gutter] 74: export const Justify = { 75: FlexStart: 0, 76: Center: 1, 77: FlexEnd: 2, 78: SpaceBetween: 3, 79: SpaceAround: 4, 80: SpaceEvenly: 5, 81: } as const 82: export type Justify = (typeof Justify)[keyof typeof Justify] 83: export const MeasureMode = { 84: Undefined: 0, 85: Exactly: 1, 86: AtMost: 2, 87: } as const 88: export type MeasureMode = (typeof MeasureMode)[keyof typeof MeasureMode] 89: export const Overflow = { 90: Visible: 0, 91: Hidden: 1, 92: Scroll: 2, 93: } as const 94: export type Overflow = (typeof Overflow)[keyof typeof Overflow] 95: export const PositionType = { 96: Static: 0, 97: Relative: 1, 98: Absolute: 2, 99: } as const 100: export type PositionType = (typeof PositionType)[keyof typeof PositionType] 101: export const Unit = { 102: Undefined: 0, 103: Point: 1, 104: Percent: 2, 105: Auto: 3, 106: } as const 107: export type Unit = (typeof Unit)[keyof typeof Unit] 108: export const Wrap = { 109: NoWrap: 0, 110: Wrap: 1, 111: WrapReverse: 2, 112: } as const 113: export type Wrap = (typeof Wrap)[keyof typeof Wrap]

File: src/native-ts/yoga-layout/index.ts

typescript 1: import { 2: Align, 3: BoxSizing, 4: Dimension, 5: Direction, 6: Display, 7: Edge, 8: Errata, 9: ExperimentalFeature, 10: FlexDirection, 11: Gutter, 12: Justify, 13: MeasureMode, 14: Overflow, 15: PositionType, 16: Unit, 17: Wrap, 18: } from './enums.js' 19: export { 20: Align, 21: BoxSizing, 22: Dimension, 23: Direction, 24: Display, 25: Edge, 26: Errata, 27: ExperimentalFeature, 28: FlexDirection, 29: Gutter, 30: Justify, 31: MeasureMode, 32: Overflow, 33: PositionType, 34: Unit, 35: Wrap, 36: } 37: export type Value = { 38: unit: Unit 39: value: number 40: } 41: const UNDEFINED_VALUE: Value = { unit: Unit.Undefined, value: NaN } 42: const AUTO_VALUE: Value = { unit: Unit.Auto, value: NaN } 43: function pointValue(v: number): Value { 44: return { unit: Unit.Point, value: v } 45: } 46: function percentValue(v: number): Value { 47: return { unit: Unit.Percent, value: v } 48: } 49: function resolveValue(v: Value, ownerSize: number): number { 50: switch (v.unit) { 51: case Unit.Point: 52: return v.value 53: case Unit.Percent: 54: return isNaN(ownerSize) ? NaN : (v.value * ownerSize) / 100 55: default: 56: return NaN 57: } 58: } 59: function isDefined(n: number): boolean { 60: return !isNaN(n) 61: } 62: function sameFloat(a: number, b: number): boolean { 63: return a === b || (a !== a && b !== b) 64: } 65: type Layout = { 66: left: number 67: top: number 68: width: number 69: height: number 70: border: [number, number, number, number] 71: padding: [number, number, number, number] 72: margin: [number, number, number, number] 73: } 74: type Style = { 75: direction: Direction 76: flexDirection: FlexDirection 77: justifyContent: Justify 78: alignItems: Align 79: alignSelf: Align 80: alignContent: Align 81: flexWrap: Wrap 82: overflow: Overflow 83: display: Display 84: positionType: PositionType 85: flexGrow: number 86: flexShrink: number 87: flexBasis: Value 88: margin: Value[] 89: padding: Value[] 90: border: Value[] 91: position: Value[] 92: gap: Value[] 93: width: Value 94: height: Value 95: minWidth: Value 96: minHeight: Value 97: maxWidth: Value 98: maxHeight: Value 99: } 100: function defaultStyle(): Style { 101: return { 102: direction: Direction.Inherit, 103: flexDirection: FlexDirection.Column, 104: justifyContent: Justify.FlexStart, 105: alignItems: Align.Stretch, 106: alignSelf: Align.Auto, 107: alignContent: Align.FlexStart, 108: flexWrap: Wrap.NoWrap, 109: overflow: Overflow.Visible, 110: display: Display.Flex, 111: positionType: PositionType.Relative, 112: flexGrow: 0, 113: flexShrink: 0, 114: flexBasis: AUTO_VALUE, 115: margin: new Array(9).fill(UNDEFINED_VALUE), 116: padding: new Array(9).fill(UNDEFINED_VALUE), 117: border: new Array(9).fill(UNDEFINED_VALUE), 118: position: new Array(9).fill(UNDEFINED_VALUE), 119: gap: new Array(3).fill(UNDEFINED_VALUE), 120: width: AUTO_VALUE, 121: height: AUTO_VALUE, 122: minWidth: UNDEFINED_VALUE, 123: minHeight: UNDEFINED_VALUE, 124: maxWidth: UNDEFINED_VALUE, 125: maxHeight: UNDEFINED_VALUE, 126: } 127: } 128: const EDGE_LEFT = 0 129: const EDGE_TOP = 1 130: const EDGE_RIGHT = 2 131: const EDGE_BOTTOM = 3 132: function resolveEdge( 133: edges: Value[], 134: physicalEdge: number, 135: ownerSize: number, 136: allowAuto = false, 137: ): number { 138: let v = edges[physicalEdge]! 139: if (v.unit === Unit.Undefined) { 140: if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) { 141: v = edges[Edge.Horizontal]! 142: } else { 143: v = edges[Edge.Vertical]! 144: } 145: } 146: if (v.unit === Unit.Undefined) { 147: v = edges[Edge.All]! 148: } 149: if (v.unit === Unit.Undefined) { 150: if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]! 151: if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]! 152: } 153: if (v.unit === Unit.Undefined) return 0 154: if (v.unit === Unit.Auto) return allowAuto ? NaN : 0 155: return resolveValue(v, ownerSize) 156: } 157: function resolveEdgeRaw(edges: Value[], physicalEdge: number): Value { 158: let v = edges[physicalEdge]! 159: if (v.unit === Unit.Undefined) { 160: if (physicalEdge === EDGE_LEFT || physicalEdge === EDGE_RIGHT) { 161: v = edges[Edge.Horizontal]! 162: } else { 163: v = edges[Edge.Vertical]! 164: } 165: } 166: if (v.unit === Unit.Undefined) v = edges[Edge.All]! 167: if (v.unit === Unit.Undefined) { 168: if (physicalEdge === EDGE_LEFT) v = edges[Edge.Start]! 169: if (physicalEdge === EDGE_RIGHT) v = edges[Edge.End]! 170: } 171: return v 172: } 173: function isMarginAuto(edges: Value[], physicalEdge: number): boolean { 174: return resolveEdgeRaw(edges, physicalEdge).unit === Unit.Auto 175: } 176: function hasAnyAutoEdge(edges: Value[]): boolean { 177: for (let i = 0; i < 9; i++) if (edges[i]!.unit === 3) return true 178: return false 179: } 180: function hasAnyDefinedEdge(edges: Value[]): boolean { 181: for (let i = 0; i < 9; i++) if (edges[i]!.unit !== 0) return true 182: return false 183: } 184: function resolveEdges4Into( 185: edges: Value[], 186: ownerSize: number, 187: out: [number, number, number, number], 188: ): void { 189: const eH = edges[6]! 190: const eV = edges[7]! 191: const eA = edges[8]! 192: const eS = edges[4]! 193: const eE = edges[5]! 194: const pctDenom = isNaN(ownerSize) ? NaN : ownerSize / 100 195: let v = edges[0]! 196: if (v.unit === 0) v = eH 197: if (v.unit === 0) v = eA 198: if (v.unit === 0) v = eS 199: out[0] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 200: v = edges[1]! 201: if (v.unit === 0) v = eV 202: if (v.unit === 0) v = eA 203: out[1] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 204: v = edges[2]! 205: if (v.unit === 0) v = eH 206: if (v.unit === 0) v = eA 207: if (v.unit === 0) v = eE 208: out[2] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 209: v = edges[3]! 210: if (v.unit === 0) v = eV 211: if (v.unit === 0) v = eA 212: out[3] = v.unit === 1 ? v.value : v.unit === 2 ? v.value * pctDenom : 0 213: } 214: function isRow(dir: FlexDirection): boolean { 215: return dir === FlexDirection.Row || dir === FlexDirection.RowReverse 216: } 217: function isReverse(dir: FlexDirection): boolean { 218: return dir === FlexDirection.RowReverse || dir === FlexDirection.ColumnReverse 219: } 220: function crossAxis(dir: FlexDirection): FlexDirection { 221: return isRow(dir) ? FlexDirection.Column : FlexDirection.Row 222: } 223: function leadingEdge(dir: FlexDirection): number { 224: switch (dir) { 225: case FlexDirection.Row: 226: return EDGE_LEFT 227: case FlexDirection.RowReverse: 228: return EDGE_RIGHT 229: case FlexDirection.Column: 230: return EDGE_TOP 231: case FlexDirection.ColumnReverse: 232: return EDGE_BOTTOM 233: } 234: } 235: function trailingEdge(dir: FlexDirection): number { 236: switch (dir) { 237: case FlexDirection.Row: 238: return EDGE_RIGHT 239: case FlexDirection.RowReverse: 240: return EDGE_LEFT 241: case FlexDirection.Column: 242: return EDGE_BOTTOM 243: case FlexDirection.ColumnReverse: 244: return EDGE_TOP 245: } 246: } 247: export type MeasureFunction = ( 248: width: number, 249: widthMode: MeasureMode, 250: height: number, 251: heightMode: MeasureMode, 252: ) => { width: number; height: number } 253: export type Size = { width: number; height: number } 254: export type Config = { 255: pointScaleFactor: number 256: errata: Errata 257: useWebDefaults: boolean 258: free(): void 259: isExperimentalFeatureEnabled(_: ExperimentalFeature): boolean 260: setExperimentalFeatureEnabled(_: ExperimentalFeature, __: boolean): void 261: setPointScaleFactor(factor: number): void 262: getErrata(): Errata 263: setErrata(errata: Errata): void 264: setUseWebDefaults(v: boolean): void 265: } 266: function createConfig(): Config { 267: const config: Config = { 268: pointScaleFactor: 1, 269: errata: Errata.None, 270: useWebDefaults: false, 271: free() {}, 272: isExperimentalFeatureEnabled() { 273: return false 274: }, 275: setExperimentalFeatureEnabled() {}, 276: setPointScaleFactor(f) { 277: config.pointScaleFactor = f 278: }, 279: getErrata() { 280: return config.errata 281: }, 282: setErrata(e) { 283: config.errata = e 284: }, 285: setUseWebDefaults(v) { 286: config.useWebDefaults = v 287: }, 288: } 289: return config 290: } 291: export class Node { 292: style: Style 293: layout: Layout 294: parent: Node | null 295: children: Node[] 296: measureFunc: MeasureFunction | null 297: config: Config 298: isDirty_: boolean 299: isReferenceBaseline_: boolean 300: _flexBasis = 0 301: _mainSize = 0 302: _crossSize = 0 303: _lineIndex = 0 304: _hasAutoMargin = false 305: _hasPosition = false 306: _hasPadding = false 307: _hasBorder = false 308: _hasMargin = false 309: _lW = NaN 310: _lH = NaN 311: _lWM: MeasureMode = 0 312: _lHM: MeasureMode = 0 313: _lOW = NaN 314: _lOH = NaN 315: _lFW = false 316: _lFH = false 317: _lOutW = NaN 318: _lOutH = NaN 319: _hasL = false 320: _mW = NaN 321: _mH = NaN 322: _mWM: MeasureMode = 0 323: _mHM: MeasureMode = 0 324: _mOW = NaN 325: _mOH = NaN 326: _mOutW = NaN 327: _mOutH = NaN 328: _hasM = false 329: _fbBasis = NaN 330: _fbOwnerW = NaN 331: _fbOwnerH = NaN 332: _fbAvailMain = NaN 333: _fbAvailCross = NaN 334: _fbCrossMode: MeasureMode = 0 335: _fbGen = -1 336: _cIn: Float64Array | null = null 337: _cOut: Float64Array | null = null 338: _cGen = -1 339: _cN = 0 340: _cWr = 0 341: constructor(config?: Config) { 342: this.style = defaultStyle() 343: this.layout = { 344: left: 0, 345: top: 0, 346: width: 0, 347: height: 0, 348: border: [0, 0, 0, 0], 349: padding: [0, 0, 0, 0], 350: margin: [0, 0, 0, 0], 351: } 352: this.parent = null 353: this.children = [] 354: this.measureFunc = null 355: this.config = config ?? DEFAULT_CONFIG 356: this.isDirty_ = true 357: this.isReferenceBaseline_ = false 358: _yogaLiveNodes++ 359: } 360: insertChild(child: Node, index: number): void { 361: child.parent = this 362: this.children.splice(index, 0, child) 363: this.markDirty() 364: } 365: removeChild(child: Node): void { 366: const idx = this.children.indexOf(child) 367: if (idx >= 0) { 368: this.children.splice(idx, 1) 369: child.parent = null 370: this.markDirty() 371: } 372: } 373: getChild(index: number): Node { 374: return this.children[index]! 375: } 376: getChildCount(): number { 377: return this.children.length 378: } 379: getParent(): Node | null { 380: return this.parent 381: } 382: free(): void { 383: this.parent = null 384: this.children = [] 385: this.measureFunc = null 386: this._cIn = null 387: this._cOut = null 388: _yogaLiveNodes-- 389: } 390: freeRecursive(): void { 391: for (const c of this.children) c.freeRecursive() 392: this.free() 393: } 394: reset(): void { 395: this.style = defaultStyle() 396: this.children = [] 397: this.parent = null 398: this.measureFunc = null 399: this.isDirty_ = true 400: this._hasAutoMargin = false 401: this._hasPosition = false 402: this._hasPadding = false 403: this._hasBorder = false 404: this._hasMargin = false 405: this._hasL = false 406: this._hasM = false 407: this._cN = 0 408: this._cWr = 0 409: this._fbBasis = NaN 410: } 411: markDirty(): void { 412: this.isDirty_ = true 413: if (this.parent && !this.parent.isDirty_) this.parent.markDirty() 414: } 415: isDirty(): boolean { 416: return this.isDirty_ 417: } 418: hasNewLayout(): boolean { 419: return true 420: } 421: markLayoutSeen(): void {} 422: setMeasureFunc(fn: MeasureFunction | null): void { 423: this.measureFunc = fn 424: this.markDirty() 425: } 426: unsetMeasureFunc(): void { 427: this.measureFunc = null 428: this.markDirty() 429: } 430: getComputedLeft(): number { 431: return this.layout.left 432: } 433: getComputedTop(): number { 434: return this.layout.top 435: } 436: getComputedWidth(): number { 437: return this.layout.width 438: } 439: getComputedHeight(): number { 440: return this.layout.height 441: } 442: getComputedRight(): number { 443: const p = this.parent 444: return p ? p.layout.width - this.layout.left - this.layout.width : 0 445: } 446: getComputedBottom(): number { 447: const p = this.parent 448: return p ? p.layout.height - this.layout.top - this.layout.height : 0 449: } 450: getComputedLayout(): { 451: left: number 452: top: number 453: right: number 454: bottom: number 455: width: number 456: height: number 457: } { 458: return { 459: left: this.layout.left, 460: top: this.layout.top, 461: right: this.getComputedRight(), 462: bottom: this.getComputedBottom(), 463: width: this.layout.width, 464: height: this.layout.height, 465: } 466: } 467: getComputedBorder(edge: Edge): number { 468: return this.layout.border[physicalEdge(edge)]! 469: } 470: getComputedPadding(edge: Edge): number { 471: return this.layout.padding[physicalEdge(edge)]! 472: } 473: getComputedMargin(edge: Edge): number { 474: return this.layout.margin[physicalEdge(edge)]! 475: } 476: setWidth(v: number | 'auto' | string | undefined): void { 477: this.style.width = parseDimension(v) 478: this.markDirty() 479: } 480: setWidthPercent(v: number): void { 481: this.style.width = percentValue(v) 482: this.markDirty() 483: } 484: setWidthAuto(): void { 485: this.style.width = AUTO_VALUE 486: this.markDirty() 487: } 488: setHeight(v: number | 'auto' | string | undefined): void { 489: this.style.height = parseDimension(v) 490: this.markDirty() 491: } 492: setHeightPercent(v: number): void { 493: this.style.height = percentValue(v) 494: this.markDirty() 495: } 496: setHeightAuto(): void { 497: this.style.height = AUTO_VALUE 498: this.markDirty() 499: } 500: setMinWidth(v: number | string | undefined): void { 501: this.style.minWidth = parseDimension(v) 502: this.markDirty() 503: } 504: setMinWidthPercent(v: number): void { 505: this.style.minWidth = percentValue(v) 506: this.markDirty() 507: } 508: setMinHeight(v: number | string | undefined): void { 509: this.style.minHeight = parseDimension(v) 510: this.markDirty() 511: } 512: setMinHeightPercent(v: number): void { 513: this.style.minHeight = percentValue(v) 514: this.markDirty() 515: } 516: setMaxWidth(v: number | string | undefined): void { 517: this.style.maxWidth = parseDimension(v) 518: this.markDirty() 519: } 520: setMaxWidthPercent(v: number): void { 521: this.style.maxWidth = percentValue(v) 522: this.markDirty() 523: } 524: setMaxHeight(v: number | string | undefined): void { 525: this.style.maxHeight = parseDimension(v) 526: this.markDirty() 527: } 528: setMaxHeightPercent(v: number): void { 529: this.style.maxHeight = percentValue(v) 530: this.markDirty() 531: } 532: setFlexDirection(dir: FlexDirection): void { 533: this.style.flexDirection = dir 534: this.markDirty() 535: } 536: setFlexGrow(v: number | undefined): void { 537: this.style.flexGrow = v ?? 0 538: this.markDirty() 539: } 540: setFlexShrink(v: number | undefined): void { 541: this.style.flexShrink = v ?? 0 542: this.markDirty() 543: } 544: setFlex(v: number | undefined): void { 545: if (v === undefined || isNaN(v)) { 546: this.style.flexGrow = 0 547: this.style.flexShrink = 0 548: } else if (v > 0) { 549: this.style.flexGrow = v 550: this.style.flexShrink = 1 551: this.style.flexBasis = pointValue(0) 552: } else if (v < 0) { 553: this.style.flexGrow = 0 554: this.style.flexShrink = -v 555: } else { 556: this.style.flexGrow = 0 557: this.style.flexShrink = 0 558: } 559: this.markDirty() 560: } 561: setFlexBasis(v: number | 'auto' | string | undefined): void { 562: this.style.flexBasis = parseDimension(v) 563: this.markDirty() 564: } 565: setFlexBasisPercent(v: number): void { 566: this.style.flexBasis = percentValue(v) 567: this.markDirty() 568: } 569: setFlexBasisAuto(): void { 570: this.style.flexBasis = AUTO_VALUE 571: this.markDirty() 572: } 573: setFlexWrap(wrap: Wrap): void { 574: this.style.flexWrap = wrap 575: this.markDirty() 576: } 577: setAlignItems(a: Align): void { 578: this.style.alignItems = a 579: this.markDirty() 580: } 581: setAlignSelf(a: Align): void { 582: this.style.alignSelf = a 583: this.markDirty() 584: } 585: setAlignContent(a: Align): void { 586: this.style.alignContent = a 587: this.markDirty() 588: } 589: setJustifyContent(j: Justify): void { 590: this.style.justifyContent = j 591: this.markDirty() 592: } 593: setDisplay(d: Display): void { 594: this.style.display = d 595: this.markDirty() 596: } 597: getDisplay(): Display { 598: return this.style.display 599: } 600: setPositionType(t: PositionType): void { 601: this.style.positionType = t 602: this.markDirty() 603: } 604: setPosition(edge: Edge, v: number | string | undefined): void { 605: this.style.position[edge] = parseDimension(v) 606: this._hasPosition = hasAnyDefinedEdge(this.style.position) 607: this.markDirty() 608: } 609: setPositionPercent(edge: Edge, v: number): void { 610: this.style.position[edge] = percentValue(v) 611: this._hasPosition = true 612: this.markDirty() 613: } 614: setPositionAuto(edge: Edge): void { 615: this.style.position[edge] = AUTO_VALUE 616: this._hasPosition = true 617: this.markDirty() 618: } 619: setOverflow(o: Overflow): void { 620: this.style.overflow = o 621: this.markDirty() 622: } 623: setDirection(d: Direction): void { 624: this.style.direction = d 625: this.markDirty() 626: } 627: setBoxSizing(_: BoxSizing): void { 628: } 629: setMargin(edge: Edge, v: number | 'auto' | string | undefined): void { 630: const val = parseDimension(v) 631: this.style.margin[edge] = val 632: if (val.unit === Unit.Auto) this._hasAutoMargin = true 633: else this._hasAutoMargin = hasAnyAutoEdge(this.style.margin) 634: this._hasMargin = 635: this._hasAutoMargin || hasAnyDefinedEdge(this.style.margin) 636: this.markDirty() 637: } 638: setMarginPercent(edge: Edge, v: number): void { 639: this.style.margin[edge] = percentValue(v) 640: this._hasAutoMargin = hasAnyAutoEdge(this.style.margin) 641: this._hasMargin = true 642: this.markDirty() 643: } 644: setMarginAuto(edge: Edge): void { 645: this.style.margin[edge] = AUTO_VALUE 646: this._hasAutoMargin = true 647: this._hasMargin = true 648: this.markDirty() 649: } 650: setPadding(edge: Edge, v: number | string | undefined): void { 651: this.style.padding[edge] = parseDimension(v) 652: this._hasPadding = hasAnyDefinedEdge(this.style.padding) 653: this.markDirty() 654: } 655: setPaddingPercent(edge: Edge, v: number): void { 656: this.style.padding[edge] = percentValue(v) 657: this._hasPadding = true 658: this.markDirty() 659: } 660: setBorder(edge: Edge, v: number | undefined): void { 661: this.style.border[edge] = v === undefined ? UNDEFINED_VALUE : pointValue(v) 662: this._hasBorder = hasAnyDefinedEdge(this.style.border) 663: this.markDirty() 664: } 665: setGap(gutter: Gutter, v: number | string | undefined): void { 666: this.style.gap[gutter] = parseDimension(v) 667: this.markDirty() 668: } 669: setGapPercent(gutter: Gutter, v: number): void { 670: this.style.gap[gutter] = percentValue(v) 671: this.markDirty() 672: } 673: getFlexDirection(): FlexDirection { 674: return this.style.flexDirection 675: } 676: getJustifyContent(): Justify { 677: return this.style.justifyContent 678: } 679: getAlignItems(): Align { 680: return this.style.alignItems 681: } 682: getAlignSelf(): Align { 683: return this.style.alignSelf 684: } 685: getAlignContent(): Align { 686: return this.style.alignContent 687: } 688: getFlexGrow(): number { 689: return this.style.flexGrow 690: } 691: getFlexShrink(): number { 692: return this.style.flexShrink 693: } 694: getFlexBasis(): Value { 695: return this.style.flexBasis 696: } 697: getFlexWrap(): Wrap { 698: return this.style.flexWrap 699: } 700: getWidth(): Value { 701: return this.style.width 702: } 703: getHeight(): Value { 704: return this.style.height 705: } 706: getOverflow(): Overflow { 707: return this.style.overflow 708: } 709: getPositionType(): PositionType { 710: return this.style.positionType 711: } 712: getDirection(): Direction { 713: return this.style.direction 714: } 715: copyStyle(_: Node): void {} 716: setDirtiedFunc(_: unknown): void {} 717: unsetDirtiedFunc(): void {} 718: setIsReferenceBaseline(v: boolean): void { 719: this.isReferenceBaseline_ = v 720: this.markDirty() 721: } 722: isReferenceBaseline(): boolean { 723: return this.isReferenceBaseline_ 724: } 725: setAspectRatio(_: number | undefined): void {} 726: getAspectRatio(): number { 727: return NaN 728: } 729: setAlwaysFormsContainingBlock(_: boolean): void {} 730: calculateLayout( 731: ownerWidth: number | undefined, 732: ownerHeight: number | undefined, 733: _direction?: Direction, 734: ): void { 735: _yogaNodesVisited = 0 736: _yogaMeasureCalls = 0 737: _yogaCacheHits = 0 738: _generation++ 739: const w = ownerWidth === undefined ? NaN : ownerWidth 740: const h = ownerHeight === undefined ? NaN : ownerHeight 741: layoutNode( 742: this, 743: w, 744: h, 745: isDefined(w) ? MeasureMode.Exactly : MeasureMode.Undefined, 746: isDefined(h) ? MeasureMode.Exactly : MeasureMode.Undefined, 747: w, 748: h, 749: true, 750: ) 751: const mar = this.layout.margin 752: const posL = resolveValue( 753: resolveEdgeRaw(this.style.position, EDGE_LEFT), 754: isDefined(w) ? w : 0, 755: ) 756: const posT = resolveValue( 757: resolveEdgeRaw(this.style.position, EDGE_TOP), 758: isDefined(w) ? w : 0, 759: ) 760: this.layout.left = mar[EDGE_LEFT] + (isDefined(posL) ? posL : 0) 761: this.layout.top = mar[EDGE_TOP] + (isDefined(posT) ? posT : 0) 762: roundLayout(this, this.config.pointScaleFactor, 0, 0) 763: } 764: } 765: const DEFAULT_CONFIG = createConfig() 766: const CACHE_SLOTS = 4 767: function cacheWrite( 768: node: Node, 769: aW: number, 770: aH: number, 771: wM: MeasureMode, 772: hM: MeasureMode, 773: oW: number, 774: oH: number, 775: fW: boolean, 776: fH: boolean, 777: wasDirty: boolean, 778: ): void { 779: if (!node._cIn) { 780: node._cIn = new Float64Array(CACHE_SLOTS * 8) 781: node._cOut = new Float64Array(CACHE_SLOTS * 2) 782: } 783: if (wasDirty && node._cGen !== _generation) { 784: node._cN = 0 785: node._cWr = 0 786: } 787: const i = node._cWr++ % CACHE_SLOTS 788: if (node._cN < CACHE_SLOTS) node._cN = node._cWr 789: const o = i * 8 790: const cIn = node._cIn 791: cIn[o] = aW 792: cIn[o + 1] = aH 793: cIn[o + 2] = wM 794: cIn[o + 3] = hM 795: cIn[o + 4] = oW 796: cIn[o + 5] = oH 797: cIn[o + 6] = fW ? 1 : 0 798: cIn[o + 7] = fH ? 1 : 0 799: node._cOut![i * 2] = node.layout.width 800: node._cOut![i * 2 + 1] = node.layout.height 801: node._cGen = _generation 802: } 803: function commitCacheOutputs(node: Node, performLayout: boolean): void { 804: if (performLayout) { 805: node._lOutW = node.layout.width 806: node._lOutH = node.layout.height 807: } else { 808: node._mOutW = node.layout.width 809: node._mOutH = node.layout.height 810: } 811: } 812: let _generation = 0 813: let _yogaNodesVisited = 0 814: let _yogaMeasureCalls = 0 815: let _yogaCacheHits = 0 816: let _yogaLiveNodes = 0 817: export function getYogaCounters(): { 818: visited: number 819: measured: number 820: cacheHits: number 821: live: number 822: } { 823: return { 824: visited: _yogaNodesVisited, 825: measured: _yogaMeasureCalls, 826: cacheHits: _yogaCacheHits, 827: live: _yogaLiveNodes, 828: } 829: } 830: function layoutNode( 831: node: Node, 832: availableWidth: number, 833: availableHeight: number, 834: widthMode: MeasureMode, 835: heightMode: MeasureMode, 836: ownerWidth: number, 837: ownerHeight: number, 838: performLayout: boolean, 839: forceWidth = false, 840: forceHeight = false, 841: ): void { 842: _yogaNodesVisited++ 843: const style = node.style 844: const layout = node.layout 845: const sameGen = node._cGen === _generation && !performLayout 846: if (!node.isDirty_ || sameGen) { 847: if ( 848: !node.isDirty_ && 849: node._hasL && 850: node._lWM === widthMode && 851: node._lHM === heightMode && 852: node._lFW === forceWidth && 853: node._lFH === forceHeight && 854: sameFloat(node._lW, availableWidth) && 855: sameFloat(node._lH, availableHeight) && 856: sameFloat(node._lOW, ownerWidth) && 857: sameFloat(node._lOH, ownerHeight) 858: ) { 859: _yogaCacheHits++ 860: layout.width = node._lOutW 861: layout.height = node._lOutH 862: return 863: } 864: if (node._cN > 0 && (sameGen || !node.isDirty_)) { 865: const cIn = node._cIn! 866: for (let i = 0; i < node._cN; i++) { 867: const o = i * 8 868: if ( 869: cIn[o + 2] === widthMode && 870: cIn[o + 3] === heightMode && 871: cIn[o + 6] === (forceWidth ? 1 : 0) && 872: cIn[o + 7] === (forceHeight ? 1 : 0) && 873: sameFloat(cIn[o]!, availableWidth) && 874: sameFloat(cIn[o + 1]!, availableHeight) && 875: sameFloat(cIn[o + 4]!, ownerWidth) && 876: sameFloat(cIn[o + 5]!, ownerHeight) 877: ) { 878: layout.width = node._cOut![i * 2]! 879: layout.height = node._cOut![i * 2 + 1]! 880: _yogaCacheHits++ 881: return 882: } 883: } 884: } 885: if ( 886: !node.isDirty_ && 887: !performLayout && 888: node._hasM && 889: node._mWM === widthMode && 890: node._mHM === heightMode && 891: sameFloat(node._mW, availableWidth) && 892: sameFloat(node._mH, availableHeight) && 893: sameFloat(node._mOW, ownerWidth) && 894: sameFloat(node._mOH, ownerHeight) 895: ) { 896: layout.width = node._mOutW 897: layout.height = node._mOutH 898: _yogaCacheHits++ 899: return 900: } 901: } 902: const wasDirty = node.isDirty_ 903: if (performLayout) { 904: node._lW = availableWidth 905: node._lH = availableHeight 906: node._lWM = widthMode 907: node._lHM = heightMode 908: node._lOW = ownerWidth 909: node._lOH = ownerHeight 910: node._lFW = forceWidth 911: node._lFH = forceHeight 912: node._hasL = true 913: node.isDirty_ = false 914: if (wasDirty) node._hasM = false 915: } else { 916: node._mW = availableWidth 917: node._mH = availableHeight 918: node._mWM = widthMode 919: node._mHM = heightMode 920: node._mOW = ownerWidth 921: node._mOH = ownerHeight 922: node._hasM = true 923: if (wasDirty) node._hasL = false 924: } 925: const pad = layout.padding 926: const bor = layout.border 927: const mar = layout.margin 928: if (node._hasPadding) resolveEdges4Into(style.padding, ownerWidth, pad) 929: else pad[0] = pad[1] = pad[2] = pad[3] = 0 930: if (node._hasBorder) resolveEdges4Into(style.border, ownerWidth, bor) 931: else bor[0] = bor[1] = bor[2] = bor[3] = 0 932: if (node._hasMargin) resolveEdges4Into(style.margin, ownerWidth, mar) 933: else mar[0] = mar[1] = mar[2] = mar[3] = 0 934: const paddingBorderWidth = pad[0] + pad[2] + bor[0] + bor[2] 935: const paddingBorderHeight = pad[1] + pad[3] + bor[1] + bor[3] 936: const styleWidth = forceWidth ? NaN : resolveValue(style.width, ownerWidth) 937: const styleHeight = forceHeight 938: ? NaN 939: : resolveValue(style.height, ownerHeight) 940: let width = availableWidth 941: let height = availableHeight 942: let wMode = widthMode 943: let hMode = heightMode 944: if (isDefined(styleWidth)) { 945: width = styleWidth 946: wMode = MeasureMode.Exactly 947: } 948: if (isDefined(styleHeight)) { 949: height = styleHeight 950: hMode = MeasureMode.Exactly 951: } 952: width = boundAxis(style, true, width, ownerWidth, ownerHeight) 953: height = boundAxis(style, false, height, ownerWidth, ownerHeight) 954: if (node.measureFunc && node.children.length === 0) { 955: const innerW = 956: wMode === MeasureMode.Undefined 957: ? NaN 958: : Math.max(0, width - paddingBorderWidth) 959: const innerH = 960: hMode === MeasureMode.Undefined 961: ? NaN 962: : Math.max(0, height - paddingBorderHeight) 963: _yogaMeasureCalls++ 964: const measured = node.measureFunc(innerW, wMode, innerH, hMode) 965: node.layout.width = 966: wMode === MeasureMode.Exactly 967: ? width 968: : boundAxis( 969: style, 970: true, 971: (measured.width ?? 0) + paddingBorderWidth, 972: ownerWidth, 973: ownerHeight, 974: ) 975: node.layout.height = 976: hMode === MeasureMode.Exactly 977: ? height 978: : boundAxis( 979: style, 980: false, 981: (measured.height ?? 0) + paddingBorderHeight, 982: ownerWidth, 983: ownerHeight, 984: ) 985: commitCacheOutputs(node, performLayout) 986: cacheWrite( 987: node, 988: availableWidth, 989: availableHeight, 990: widthMode, 991: heightMode, 992: ownerWidth, 993: ownerHeight, 994: forceWidth, 995: forceHeight, 996: wasDirty, 997: ) 998: return 999: } 1000: if (node.children.length === 0) { 1001: node.layout.width = 1002: wMode === MeasureMode.Exactly 1003: ? width 1004: : boundAxis(style, true, paddingBorderWidth, ownerWidth, ownerHeight) 1005: node.layout.height = 1006: hMode === MeasureMode.Exactly 1007: ? height 1008: : boundAxis(style, false, paddingBorderHeight, ownerWidth, ownerHeight) 1009: commitCacheOutputs(node, performLayout) 1010: cacheWrite( 1011: node, 1012: availableWidth, 1013: availableHeight, 1014: widthMode, 1015: heightMode, 1016: ownerWidth, 1017: ownerHeight, 1018: forceWidth, 1019: forceHeight, 1020: wasDirty, 1021: ) 1022: return 1023: } 1024: const mainAxis = style.flexDirection 1025: const crossAx = crossAxis(mainAxis) 1026: const isMainRow = isRow(mainAxis) 1027: const mainSize = isMainRow ? width : height 1028: const crossSize = isMainRow ? height : width 1029: const mainMode = isMainRow ? wMode : hMode 1030: const crossMode = isMainRow ? hMode : wMode 1031: const mainPadBorder = isMainRow ? paddingBorderWidth : paddingBorderHeight 1032: const crossPadBorder = isMainRow ? paddingBorderHeight : paddingBorderWidth 1033: const innerMainSize = isDefined(mainSize) 1034: ? Math.max(0, mainSize - mainPadBorder) 1035: : NaN 1036: const innerCrossSize = isDefined(crossSize) 1037: ? Math.max(0, crossSize - crossPadBorder) 1038: : NaN 1039: const gapMain = resolveGap( 1040: style, 1041: isMainRow ? Gutter.Column : Gutter.Row, 1042: innerMainSize, 1043: ) 1044: const flowChildren: Node[] = [] 1045: const absChildren: Node[] = [] 1046: collectLayoutChildren(node, flowChildren, absChildren) 1047: const ownerW = isDefined(width) ? width : NaN 1048: const ownerH = isDefined(height) ? height : NaN 1049: const isWrap = style.flexWrap !== Wrap.NoWrap 1050: const gapCross = resolveGap( 1051: style, 1052: isMainRow ? Gutter.Row : Gutter.Column, 1053: innerCrossSize, 1054: ) 1055: for (const c of flowChildren) { 1056: c._flexBasis = computeFlexBasis( 1057: c, 1058: mainAxis, 1059: innerMainSize, 1060: innerCrossSize, 1061: crossMode, 1062: ownerW, 1063: ownerH, 1064: ) 1065: } 1066: const lines: Node[][] = [] 1067: if (!isWrap || !isDefined(innerMainSize) || flowChildren.length === 0) { 1068: for (const c of flowChildren) c._lineIndex = 0 1069: lines.push(flowChildren) 1070: } else { 1071: let lineStart = 0 1072: let lineLen = 0 1073: for (let i = 0; i < flowChildren.length; i++) { 1074: const c = flowChildren[i]! 1075: const hypo = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH) 1076: const outer = Math.max(0, hypo) + childMarginForAxis(c, mainAxis, ownerW) 1077: const withGap = i > lineStart ? gapMain : 0 1078: if (i > lineStart && lineLen + withGap + outer > innerMainSize) { 1079: lines.push(flowChildren.slice(lineStart, i)) 1080: lineStart = i 1081: lineLen = outer 1082: } else { 1083: lineLen += withGap + outer 1084: } 1085: c._lineIndex = lines.length 1086: } 1087: lines.push(flowChildren.slice(lineStart)) 1088: } 1089: const lineCount = lines.length 1090: const isBaseline = isBaselineLayout(node, flowChildren) 1091: const lineConsumedMain: number[] = new Array(lineCount) 1092: const lineCrossSizes: number[] = new Array(lineCount) 1093: const lineMaxAscent: number[] = isBaseline ? new Array(lineCount).fill(0) : [] 1094: let maxLineMain = 0 1095: let totalLinesCross = 0 1096: for (let li = 0; li < lineCount; li++) { 1097: const line = lines[li]! 1098: const lineGap = line.length > 1 ? gapMain * (line.length - 1) : 0 1099: let lineBasis = lineGap 1100: for (const c of line) { 1101: lineBasis += c._flexBasis + childMarginForAxis(c, mainAxis, ownerW) 1102: } 1103: let availMain = innerMainSize 1104: if (!isDefined(availMain)) { 1105: const mainOwner = isMainRow ? ownerWidth : ownerHeight 1106: const minM = resolveValue( 1107: isMainRow ? style.minWidth : style.minHeight, 1108: mainOwner, 1109: ) 1110: const maxM = resolveValue( 1111: isMainRow ? style.maxWidth : style.maxHeight, 1112: mainOwner, 1113: ) 1114: if (isDefined(maxM) && lineBasis > maxM - mainPadBorder) { 1115: availMain = Math.max(0, maxM - mainPadBorder) 1116: } else if (isDefined(minM) && lineBasis < minM - mainPadBorder) { 1117: availMain = Math.max(0, minM - mainPadBorder) 1118: } 1119: } 1120: resolveFlexibleLengths( 1121: line, 1122: availMain, 1123: lineBasis, 1124: isMainRow, 1125: ownerW, 1126: ownerH, 1127: ) 1128: let lineCross = 0 1129: for (const c of line) { 1130: const cStyle = c.style 1131: const childAlign = 1132: cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf 1133: const cMarginCross = childMarginForAxis(c, crossAx, ownerW) 1134: let childCrossSize = NaN 1135: let childCrossMode: MeasureMode = MeasureMode.Undefined 1136: const resolvedCrossStyle = resolveValue( 1137: isMainRow ? cStyle.height : cStyle.width, 1138: isMainRow ? ownerH : ownerW, 1139: ) 1140: const crossLeadE = isMainRow ? EDGE_TOP : EDGE_LEFT 1141: const crossTrailE = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT 1142: const hasCrossAutoMargin = 1143: c._hasAutoMargin && 1144: (isMarginAuto(cStyle.margin, crossLeadE) || 1145: isMarginAuto(cStyle.margin, crossTrailE)) 1146: if (isDefined(resolvedCrossStyle)) { 1147: childCrossSize = resolvedCrossStyle 1148: childCrossMode = MeasureMode.Exactly 1149: } else if ( 1150: childAlign === Align.Stretch && 1151: !hasCrossAutoMargin && 1152: !isWrap && 1153: isDefined(innerCrossSize) && 1154: crossMode === MeasureMode.Exactly 1155: ) { 1156: childCrossSize = Math.max(0, innerCrossSize - cMarginCross) 1157: childCrossMode = MeasureMode.Exactly 1158: } else if (!isWrap && isDefined(innerCrossSize)) { 1159: childCrossSize = Math.max(0, innerCrossSize - cMarginCross) 1160: childCrossMode = MeasureMode.AtMost 1161: } 1162: const cw = isMainRow ? c._mainSize : childCrossSize 1163: const ch = isMainRow ? childCrossSize : c._mainSize 1164: layoutNode( 1165: c, 1166: cw, 1167: ch, 1168: isMainRow ? MeasureMode.Exactly : childCrossMode, 1169: isMainRow ? childCrossMode : MeasureMode.Exactly, 1170: ownerW, 1171: ownerH, 1172: performLayout, 1173: isMainRow, 1174: !isMainRow, 1175: ) 1176: c._crossSize = isMainRow ? c.layout.height : c.layout.width 1177: lineCross = Math.max(lineCross, c._crossSize + cMarginCross) 1178: } 1179: if (isBaseline) { 1180: let maxAscent = 0 1181: let maxDescent = 0 1182: for (const c of line) { 1183: if (resolveChildAlign(node, c) !== Align.Baseline) continue 1184: const mTop = resolveEdge(c.style.margin, EDGE_TOP, ownerW) 1185: const mBot = resolveEdge(c.style.margin, EDGE_BOTTOM, ownerW) 1186: const ascent = calculateBaseline(c) + mTop 1187: const descent = c.layout.height + mTop + mBot - ascent 1188: if (ascent > maxAscent) maxAscent = ascent 1189: if (descent > maxDescent) maxDescent = descent 1190: } 1191: lineMaxAscent[li] = maxAscent 1192: if (maxAscent + maxDescent > lineCross) { 1193: lineCross = maxAscent + maxDescent 1194: } 1195: } 1196: const mainLead = leadingEdge(mainAxis) 1197: const mainTrail = trailingEdge(mainAxis) 1198: let consumed = lineGap 1199: for (const c of line) { 1200: const cm = c.layout.margin 1201: consumed += c._mainSize + cm[mainLead]! + cm[mainTrail]! 1202: } 1203: lineConsumedMain[li] = consumed 1204: lineCrossSizes[li] = lineCross 1205: maxLineMain = Math.max(maxLineMain, consumed) 1206: totalLinesCross += lineCross 1207: } 1208: const totalCrossGap = lineCount > 1 ? gapCross * (lineCount - 1) : 0 1209: totalLinesCross += totalCrossGap 1210: const isScroll = style.overflow === Overflow.Scroll 1211: const contentMain = maxLineMain + mainPadBorder 1212: const finalMainSize = 1213: mainMode === MeasureMode.Exactly 1214: ? mainSize 1215: : mainMode === MeasureMode.AtMost && isScroll 1216: ? Math.max(Math.min(mainSize, contentMain), mainPadBorder) 1217: : isWrap && lineCount > 1 && mainMode === MeasureMode.AtMost 1218: ? mainSize 1219: : contentMain 1220: const contentCross = totalLinesCross + crossPadBorder 1221: const finalCrossSize = 1222: crossMode === MeasureMode.Exactly 1223: ? crossSize 1224: : crossMode === MeasureMode.AtMost && isScroll 1225: ? Math.max(Math.min(crossSize, contentCross), crossPadBorder) 1226: : contentCross 1227: node.layout.width = boundAxis( 1228: style, 1229: true, 1230: isMainRow ? finalMainSize : finalCrossSize, 1231: ownerWidth, 1232: ownerHeight, 1233: ) 1234: node.layout.height = boundAxis( 1235: style, 1236: false, 1237: isMainRow ? finalCrossSize : finalMainSize, 1238: ownerWidth, 1239: ownerHeight, 1240: ) 1241: commitCacheOutputs(node, performLayout) 1242: cacheWrite( 1243: node, 1244: availableWidth, 1245: availableHeight, 1246: widthMode, 1247: heightMode, 1248: ownerWidth, 1249: ownerHeight, 1250: forceWidth, 1251: forceHeight, 1252: wasDirty, 1253: ) 1254: if (!performLayout) return 1255: const actualInnerMain = 1256: (isMainRow ? node.layout.width : node.layout.height) - mainPadBorder 1257: const actualInnerCross = 1258: (isMainRow ? node.layout.height : node.layout.width) - crossPadBorder 1259: const mainLeadEdgePhys = leadingEdge(mainAxis) 1260: const mainTrailEdgePhys = trailingEdge(mainAxis) 1261: const crossLeadEdgePhys = isMainRow ? EDGE_TOP : EDGE_LEFT 1262: const crossTrailEdgePhys = isMainRow ? EDGE_BOTTOM : EDGE_RIGHT 1263: const reversed = isReverse(mainAxis) 1264: const mainContainerSize = isMainRow ? node.layout.width : node.layout.height 1265: const crossLead = pad[crossLeadEdgePhys]! + bor[crossLeadEdgePhys]! 1266: let lineCrossOffset = crossLead 1267: let betweenLines = gapCross 1268: const freeCross = actualInnerCross - totalLinesCross 1269: if (lineCount === 1 && !isWrap && !isBaseline) { 1270: lineCrossSizes[0] = actualInnerCross 1271: } else { 1272: const remCross = Math.max(0, freeCross) 1273: switch (style.alignContent) { 1274: case Align.FlexStart: 1275: break 1276: case Align.Center: 1277: lineCrossOffset += freeCross / 2 1278: break 1279: case Align.FlexEnd: 1280: lineCrossOffset += freeCross 1281: break 1282: case Align.Stretch: 1283: if (lineCount > 0 && remCross > 0) { 1284: const add = remCross / lineCount 1285: for (let i = 0; i < lineCount; i++) lineCrossSizes[i]! += add 1286: } 1287: break 1288: case Align.SpaceBetween: 1289: if (lineCount > 1) betweenLines += remCross / (lineCount - 1) 1290: break 1291: case Align.SpaceAround: 1292: if (lineCount > 0) { 1293: betweenLines += remCross / lineCount 1294: lineCrossOffset += remCross / lineCount / 2 1295: } 1296: break 1297: case Align.SpaceEvenly: 1298: if (lineCount > 0) { 1299: betweenLines += remCross / (lineCount + 1) 1300: lineCrossOffset += remCross / (lineCount + 1) 1301: } 1302: break 1303: default: 1304: break 1305: } 1306: } 1307: const wrapReverse = style.flexWrap === Wrap.WrapReverse 1308: const crossContainerSize = isMainRow ? node.layout.height : node.layout.width 1309: let lineCrossPos = lineCrossOffset 1310: for (let li = 0; li < lineCount; li++) { 1311: const line = lines[li]! 1312: const lineCross = lineCrossSizes[li]! 1313: const consumedMain = lineConsumedMain[li]! 1314: const n = line.length 1315: if (isWrap || crossMode !== MeasureMode.Exactly) { 1316: for (const c of line) { 1317: const cStyle = c.style 1318: const childAlign = 1319: cStyle.alignSelf === Align.Auto ? style.alignItems : cStyle.alignSelf 1320: const crossStyleDef = isDefined( 1321: resolveValue( 1322: isMainRow ? cStyle.height : cStyle.width, 1323: isMainRow ? ownerH : ownerW, 1324: ), 1325: ) 1326: const hasCrossAutoMargin = 1327: c._hasAutoMargin && 1328: (isMarginAuto(cStyle.margin, crossLeadEdgePhys) || 1329: isMarginAuto(cStyle.margin, crossTrailEdgePhys)) 1330: if ( 1331: childAlign === Align.Stretch && 1332: !crossStyleDef && 1333: !hasCrossAutoMargin 1334: ) { 1335: const cMarginCross = childMarginForAxis(c, crossAx, ownerW) 1336: const target = Math.max(0, lineCross - cMarginCross) 1337: if (c._crossSize !== target) { 1338: const cw = isMainRow ? c._mainSize : target 1339: const ch = isMainRow ? target : c._mainSize 1340: layoutNode( 1341: c, 1342: cw, 1343: ch, 1344: MeasureMode.Exactly, 1345: MeasureMode.Exactly, 1346: ownerW, 1347: ownerH, 1348: performLayout, 1349: isMainRow, 1350: !isMainRow, 1351: ) 1352: c._crossSize = target 1353: } 1354: } 1355: } 1356: } 1357: let mainOffset = pad[mainLeadEdgePhys]! + bor[mainLeadEdgePhys]! 1358: let betweenMain = gapMain 1359: let numAutoMarginsMain = 0 1360: for (const c of line) { 1361: if (!c._hasAutoMargin) continue 1362: if (isMarginAuto(c.style.margin, mainLeadEdgePhys)) numAutoMarginsMain++ 1363: if (isMarginAuto(c.style.margin, mainTrailEdgePhys)) numAutoMarginsMain++ 1364: } 1365: const freeMain = actualInnerMain - consumedMain 1366: const remainingMain = Math.max(0, freeMain) 1367: const autoMarginMainSize = 1368: numAutoMarginsMain > 0 && remainingMain > 0 1369: ? remainingMain / numAutoMarginsMain 1370: : 0 1371: if (numAutoMarginsMain === 0) { 1372: switch (style.justifyContent) { 1373: case Justify.FlexStart: 1374: break 1375: case Justify.Center: 1376: mainOffset += freeMain / 2 1377: break 1378: case Justify.FlexEnd: 1379: mainOffset += freeMain 1380: break 1381: case Justify.SpaceBetween: 1382: if (n > 1) betweenMain += remainingMain / (n - 1) 1383: break 1384: case Justify.SpaceAround: 1385: if (n > 0) { 1386: betweenMain += remainingMain / n 1387: mainOffset += remainingMain / n / 2 1388: } 1389: break 1390: case Justify.SpaceEvenly: 1391: if (n > 0) { 1392: betweenMain += remainingMain / (n + 1) 1393: mainOffset += remainingMain / (n + 1) 1394: } 1395: break 1396: } 1397: } 1398: const effectiveLineCrossPos = wrapReverse 1399: ? crossContainerSize - lineCrossPos - lineCross 1400: : lineCrossPos 1401: let pos = mainOffset 1402: for (const c of line) { 1403: const cMargin = c.style.margin 1404: const cLayoutMargin = c.layout.margin 1405: let autoMainLead = false 1406: let autoMainTrail = false 1407: let autoCrossLead = false 1408: let autoCrossTrail = false 1409: let mMainLead: number 1410: let mMainTrail: number 1411: let mCrossLead: number 1412: let mCrossTrail: number 1413: if (c._hasAutoMargin) { 1414: autoMainLead = isMarginAuto(cMargin, mainLeadEdgePhys) 1415: autoMainTrail = isMarginAuto(cMargin, mainTrailEdgePhys) 1416: autoCrossLead = isMarginAuto(cMargin, crossLeadEdgePhys) 1417: autoCrossTrail = isMarginAuto(cMargin, crossTrailEdgePhys) 1418: mMainLead = autoMainLead 1419: ? autoMarginMainSize 1420: : cLayoutMargin[mainLeadEdgePhys]! 1421: mMainTrail = autoMainTrail 1422: ? autoMarginMainSize 1423: : cLayoutMargin[mainTrailEdgePhys]! 1424: mCrossLead = autoCrossLead ? 0 : cLayoutMargin[crossLeadEdgePhys]! 1425: mCrossTrail = autoCrossTrail ? 0 : cLayoutMargin[crossTrailEdgePhys]! 1426: } else { 1427: mMainLead = cLayoutMargin[mainLeadEdgePhys]! 1428: mMainTrail = cLayoutMargin[mainTrailEdgePhys]! 1429: mCrossLead = cLayoutMargin[crossLeadEdgePhys]! 1430: mCrossTrail = cLayoutMargin[crossTrailEdgePhys]! 1431: } 1432: const mainPos = reversed 1433: ? mainContainerSize - (pos + mMainLead) - c._mainSize 1434: : pos + mMainLead 1435: const childAlign = 1436: c.style.alignSelf === Align.Auto ? style.alignItems : c.style.alignSelf 1437: let crossPos = effectiveLineCrossPos + mCrossLead 1438: const crossFree = lineCross - c._crossSize - mCrossLead - mCrossTrail 1439: if (autoCrossLead && autoCrossTrail) { 1440: crossPos += Math.max(0, crossFree) / 2 1441: } else if (autoCrossLead) { 1442: crossPos += Math.max(0, crossFree) 1443: } else if (autoCrossTrail) { 1444: } else { 1445: switch (childAlign) { 1446: case Align.FlexStart: 1447: case Align.Stretch: 1448: if (wrapReverse) crossPos += crossFree 1449: break 1450: case Align.Center: 1451: crossPos += crossFree / 2 1452: break 1453: case Align.FlexEnd: 1454: if (!wrapReverse) crossPos += crossFree 1455: break 1456: case Align.Baseline: 1457: if (isBaseline) { 1458: crossPos = 1459: effectiveLineCrossPos + 1460: lineMaxAscent[li]! - 1461: calculateBaseline(c) 1462: } 1463: break 1464: default: 1465: break 1466: } 1467: } 1468: let relX = 0 1469: let relY = 0 1470: if (c._hasPosition) { 1471: const relLeft = resolveValue( 1472: resolveEdgeRaw(c.style.position, EDGE_LEFT), 1473: ownerW, 1474: ) 1475: const relRight = resolveValue( 1476: resolveEdgeRaw(c.style.position, EDGE_RIGHT), 1477: ownerW, 1478: ) 1479: const relTop = resolveValue( 1480: resolveEdgeRaw(c.style.position, EDGE_TOP), 1481: ownerW, 1482: ) 1483: const relBottom = resolveValue( 1484: resolveEdgeRaw(c.style.position, EDGE_BOTTOM), 1485: ownerW, 1486: ) 1487: relX = isDefined(relLeft) 1488: ? relLeft 1489: : isDefined(relRight) 1490: ? -relRight 1491: : 0 1492: relY = isDefined(relTop) 1493: ? relTop 1494: : isDefined(relBottom) 1495: ? -relBottom 1496: : 0 1497: } 1498: if (isMainRow) { 1499: c.layout.left = mainPos + relX 1500: c.layout.top = crossPos + relY 1501: } else { 1502: c.layout.left = crossPos + relX 1503: c.layout.top = mainPos + relY 1504: } 1505: pos += c._mainSize + mMainLead + mMainTrail + betweenMain 1506: } 1507: lineCrossPos += lineCross + betweenLines 1508: } 1509: for (const c of absChildren) { 1510: layoutAbsoluteChild( 1511: node, 1512: c, 1513: node.layout.width, 1514: node.layout.height, 1515: pad, 1516: bor, 1517: ) 1518: } 1519: } 1520: function layoutAbsoluteChild( 1521: parent: Node, 1522: child: Node, 1523: parentWidth: number, 1524: parentHeight: number, 1525: pad: [number, number, number, number], 1526: bor: [number, number, number, number], 1527: ): void { 1528: const cs = child.style 1529: const posLeft = resolveEdgeRaw(cs.position, EDGE_LEFT) 1530: const posRight = resolveEdgeRaw(cs.position, EDGE_RIGHT) 1531: const posTop = resolveEdgeRaw(cs.position, EDGE_TOP) 1532: const posBottom = resolveEdgeRaw(cs.position, EDGE_BOTTOM) 1533: const rLeft = resolveValue(posLeft, parentWidth) 1534: const rRight = resolveValue(posRight, parentWidth) 1535: const rTop = resolveValue(posTop, parentHeight) 1536: const rBottom = resolveValue(posBottom, parentHeight) 1537: const paddingBoxW = parentWidth - bor[0] - bor[2] 1538: const paddingBoxH = parentHeight - bor[1] - bor[3] 1539: let cw = resolveValue(cs.width, paddingBoxW) 1540: let ch = resolveValue(cs.height, paddingBoxH) 1541: if (!isDefined(cw) && isDefined(rLeft) && isDefined(rRight)) { 1542: cw = paddingBoxW - rLeft - rRight 1543: } 1544: if (!isDefined(ch) && isDefined(rTop) && isDefined(rBottom)) { 1545: ch = paddingBoxH - rTop - rBottom 1546: } 1547: layoutNode( 1548: child, 1549: cw, 1550: ch, 1551: isDefined(cw) ? MeasureMode.Exactly : MeasureMode.Undefined, 1552: isDefined(ch) ? MeasureMode.Exactly : MeasureMode.Undefined, 1553: paddingBoxW, 1554: paddingBoxH, 1555: true, 1556: ) 1557: const mL = resolveEdge(cs.margin, EDGE_LEFT, parentWidth) 1558: const mT = resolveEdge(cs.margin, EDGE_TOP, parentWidth) 1559: const mR = resolveEdge(cs.margin, EDGE_RIGHT, parentWidth) 1560: const mB = resolveEdge(cs.margin, EDGE_BOTTOM, parentWidth) 1561: const mainAxis = parent.style.flexDirection 1562: const reversed = isReverse(mainAxis) 1563: const mainRow = isRow(mainAxis) 1564: const wrapReverse = parent.style.flexWrap === Wrap.WrapReverse 1565: const alignment = 1566: cs.alignSelf === Align.Auto ? parent.style.alignItems : cs.alignSelf 1567: let left: number 1568: if (isDefined(rLeft)) { 1569: left = bor[0] + rLeft + mL 1570: } else if (isDefined(rRight)) { 1571: left = parentWidth - bor[2] - rRight - child.layout.width - mR 1572: } else if (mainRow) { 1573: const lead = pad[0] + bor[0] 1574: const trail = parentWidth - pad[2] - bor[2] 1575: left = reversed 1576: ? trail - child.layout.width - mR 1577: : justifyAbsolute( 1578: parent.style.justifyContent, 1579: lead, 1580: trail, 1581: child.layout.width, 1582: ) + mL 1583: } else { 1584: left = 1585: alignAbsolute( 1586: alignment, 1587: pad[0] + bor[0], 1588: parentWidth - pad[2] - bor[2], 1589: child.layout.width, 1590: wrapReverse, 1591: ) + mL 1592: } 1593: let top: number 1594: if (isDefined(rTop)) { 1595: top = bor[1] + rTop + mT 1596: } else if (isDefined(rBottom)) { 1597: top = parentHeight - bor[3] - rBottom - child.layout.height - mB 1598: } else if (mainRow) { 1599: top = 1600: alignAbsolute( 1601: alignment, 1602: pad[1] + bor[1], 1603: parentHeight - pad[3] - bor[3], 1604: child.layout.height, 1605: wrapReverse, 1606: ) + mT 1607: } else { 1608: const lead = pad[1] + bor[1] 1609: const trail = parentHeight - pad[3] - bor[3] 1610: top = reversed 1611: ? trail - child.layout.height - mB 1612: : justifyAbsolute( 1613: parent.style.justifyContent, 1614: lead, 1615: trail, 1616: child.layout.height, 1617: ) + mT 1618: } 1619: child.layout.left = left 1620: child.layout.top = top 1621: } 1622: function justifyAbsolute( 1623: justify: Justify, 1624: leadEdge: number, 1625: trailEdge: number, 1626: childSize: number, 1627: ): number { 1628: switch (justify) { 1629: case Justify.Center: 1630: return leadEdge + (trailEdge - leadEdge - childSize) / 2 1631: case Justify.FlexEnd: 1632: return trailEdge - childSize 1633: default: 1634: return leadEdge 1635: } 1636: } 1637: function alignAbsolute( 1638: align: Align, 1639: leadEdge: number, 1640: trailEdge: number, 1641: childSize: number, 1642: wrapReverse: boolean, 1643: ): number { 1644: switch (align) { 1645: case Align.Center: 1646: return leadEdge + (trailEdge - leadEdge - childSize) / 2 1647: case Align.FlexEnd: 1648: return wrapReverse ? leadEdge : trailEdge - childSize 1649: default: 1650: return wrapReverse ? trailEdge - childSize : leadEdge 1651: } 1652: } 1653: function computeFlexBasis( 1654: child: Node, 1655: mainAxis: FlexDirection, 1656: availableMain: number, 1657: availableCross: number, 1658: crossMode: MeasureMode, 1659: ownerWidth: number, 1660: ownerHeight: number, 1661: ): number { 1662: const sameGen = child._fbGen === _generation 1663: if ( 1664: (sameGen || !child.isDirty_) && 1665: child._fbCrossMode === crossMode && 1666: sameFloat(child._fbOwnerW, ownerWidth) && 1667: sameFloat(child._fbOwnerH, ownerHeight) && 1668: sameFloat(child._fbAvailMain, availableMain) && 1669: sameFloat(child._fbAvailCross, availableCross) 1670: ) { 1671: return child._fbBasis 1672: } 1673: const cs = child.style 1674: const isMainRow = isRow(mainAxis) 1675: const basis = resolveValue(cs.flexBasis, availableMain) 1676: if (isDefined(basis)) { 1677: const b = Math.max(0, basis) 1678: child._fbBasis = b 1679: child._fbOwnerW = ownerWidth 1680: child._fbOwnerH = ownerHeight 1681: child._fbAvailMain = availableMain 1682: child._fbAvailCross = availableCross 1683: child._fbCrossMode = crossMode 1684: child._fbGen = _generation 1685: return b 1686: } 1687: const mainStyleDim = isMainRow ? cs.width : cs.height 1688: const mainOwner = isMainRow ? ownerWidth : ownerHeight 1689: const resolved = resolveValue(mainStyleDim, mainOwner) 1690: if (isDefined(resolved)) { 1691: const b = Math.max(0, resolved) 1692: child._fbBasis = b 1693: child._fbOwnerW = ownerWidth 1694: child._fbOwnerH = ownerHeight 1695: child._fbAvailMain = availableMain 1696: child._fbAvailCross = availableCross 1697: child._fbCrossMode = crossMode 1698: child._fbGen = _generation 1699: return b 1700: } 1701: const crossStyleDim = isMainRow ? cs.height : cs.width 1702: const crossOwner = isMainRow ? ownerHeight : ownerWidth 1703: let crossConstraint = resolveValue(crossStyleDim, crossOwner) 1704: let crossConstraintMode: MeasureMode = isDefined(crossConstraint) 1705: ? MeasureMode.Exactly 1706: : MeasureMode.Undefined 1707: if (!isDefined(crossConstraint) && isDefined(availableCross)) { 1708: crossConstraint = availableCross 1709: crossConstraintMode = 1710: crossMode === MeasureMode.Exactly && isStretchAlign(child) 1711: ? MeasureMode.Exactly 1712: : MeasureMode.AtMost 1713: } 1714: let mainConstraint = NaN 1715: let mainConstraintMode: MeasureMode = MeasureMode.Undefined 1716: if (isMainRow && isDefined(availableMain) && hasMeasureFuncInSubtree(child)) { 1717: mainConstraint = availableMain 1718: mainConstraintMode = MeasureMode.AtMost 1719: } 1720: const mw = isMainRow ? mainConstraint : crossConstraint 1721: const mh = isMainRow ? crossConstraint : mainConstraint 1722: const mwMode = isMainRow ? mainConstraintMode : crossConstraintMode 1723: const mhMode = isMainRow ? crossConstraintMode : mainConstraintMode 1724: layoutNode(child, mw, mh, mwMode, mhMode, ownerWidth, ownerHeight, false) 1725: const b = isMainRow ? child.layout.width : child.layout.height 1726: child._fbBasis = b 1727: child._fbOwnerW = ownerWidth 1728: child._fbOwnerH = ownerHeight 1729: child._fbAvailMain = availableMain 1730: child._fbAvailCross = availableCross 1731: child._fbCrossMode = crossMode 1732: child._fbGen = _generation 1733: return b 1734: } 1735: function hasMeasureFuncInSubtree(node: Node): boolean { 1736: if (node.measureFunc) return true 1737: for (const c of node.children) { 1738: if (hasMeasureFuncInSubtree(c)) return true 1739: } 1740: return false 1741: } 1742: function resolveFlexibleLengths( 1743: children: Node[], 1744: availableInnerMain: number, 1745: totalFlexBasis: number, 1746: isMainRow: boolean, 1747: ownerW: number, 1748: ownerH: number, 1749: ): void { 1750: const n = children.length 1751: const frozen: boolean[] = new Array(n).fill(false) 1752: const initialFree = isDefined(availableInnerMain) 1753: ? availableInnerMain - totalFlexBasis 1754: : 0 1755: for (let i = 0; i < n; i++) { 1756: const c = children[i]! 1757: const clamped = boundAxis(c.style, isMainRow, c._flexBasis, ownerW, ownerH) 1758: const inflexible = 1759: !isDefined(availableInnerMain) || 1760: (initialFree >= 0 ? c.style.flexGrow === 0 : c.style.flexShrink === 0) 1761: if (inflexible) { 1762: c._mainSize = Math.max(0, clamped) 1763: frozen[i] = true 1764: } else { 1765: c._mainSize = c._flexBasis 1766: } 1767: } 1768: const unclamped: number[] = new Array(n) 1769: for (let iter = 0; iter <= n; iter++) { 1770: let frozenDelta = 0 1771: let totalGrow = 0 1772: let totalShrinkScaled = 0 1773: let unfrozenCount = 0 1774: for (let i = 0; i < n; i++) { 1775: const c = children[i]! 1776: if (frozen[i]) { 1777: frozenDelta += c._mainSize - c._flexBasis 1778: } else { 1779: totalGrow += c.style.flexGrow 1780: totalShrinkScaled += c.style.flexShrink * c._flexBasis 1781: unfrozenCount++ 1782: } 1783: } 1784: if (unfrozenCount === 0) break 1785: let remaining = initialFree - frozenDelta 1786: if (remaining > 0 && totalGrow > 0 && totalGrow < 1) { 1787: const scaled = initialFree * totalGrow 1788: if (scaled < remaining) remaining = scaled 1789: } else if (remaining < 0 && totalShrinkScaled > 0) { 1790: let totalShrink = 0 1791: for (let i = 0; i < n; i++) { 1792: if (!frozen[i]) totalShrink += children[i]!.style.flexShrink 1793: } 1794: if (totalShrink < 1) { 1795: const scaled = initialFree * totalShrink 1796: if (scaled > remaining) remaining = scaled 1797: } 1798: } 1799: let totalViolation = 0 1800: for (let i = 0; i < n; i++) { 1801: if (frozen[i]) continue 1802: const c = children[i]! 1803: let t = c._flexBasis 1804: if (remaining > 0 && totalGrow > 0) { 1805: t += (remaining * c.style.flexGrow) / totalGrow 1806: } else if (remaining < 0 && totalShrinkScaled > 0) { 1807: t += 1808: (remaining * (c.style.flexShrink * c._flexBasis)) / totalShrinkScaled 1809: } 1810: unclamped[i] = t 1811: const clamped = Math.max( 1812: 0, 1813: boundAxis(c.style, isMainRow, t, ownerW, ownerH), 1814: ) 1815: c._mainSize = clamped 1816: totalViolation += clamped - t 1817: } 1818: if (totalViolation === 0) break 1819: let anyFrozen = false 1820: for (let i = 0; i < n; i++) { 1821: if (frozen[i]) continue 1822: const v = children[i]!._mainSize - unclamped[i]! 1823: if ((totalViolation > 0 && v > 0) || (totalViolation < 0 && v < 0)) { 1824: frozen[i] = true 1825: anyFrozen = true 1826: } 1827: } 1828: if (!anyFrozen) break 1829: } 1830: } 1831: function isStretchAlign(child: Node): boolean { 1832: const p = child.parent 1833: if (!p) return false 1834: const align = 1835: child.style.alignSelf === Align.Auto 1836: ? p.style.alignItems 1837: : child.style.alignSelf 1838: return align === Align.Stretch 1839: } 1840: function resolveChildAlign(parent: Node, child: Node): Align { 1841: return child.style.alignSelf === Align.Auto 1842: ? parent.style.alignItems 1843: : child.style.alignSelf 1844: } 1845: function calculateBaseline(node: Node): number { 1846: let baselineChild: Node | null = null 1847: for (const c of node.children) { 1848: if (c._lineIndex > 0) break 1849: if (c.style.positionType === PositionType.Absolute) continue 1850: if (c.style.display === Display.None) continue 1851: if ( 1852: resolveChildAlign(node, c) === Align.Baseline || 1853: c.isReferenceBaseline_ 1854: ) { 1855: baselineChild = c 1856: break 1857: } 1858: if (baselineChild === null) baselineChild = c 1859: } 1860: if (baselineChild === null) return node.layout.height 1861: return calculateBaseline(baselineChild) + baselineChild.layout.top 1862: } 1863: function isBaselineLayout(node: Node, flowChildren: Node[]): boolean { 1864: if (!isRow(node.style.flexDirection)) return false 1865: if (node.style.alignItems === Align.Baseline) return true 1866: for (const c of flowChildren) { 1867: if (c.style.alignSelf === Align.Baseline) return true 1868: } 1869: return false 1870: } 1871: function childMarginForAxis( 1872: child: Node, 1873: axis: FlexDirection, 1874: ownerWidth: number, 1875: ): number { 1876: if (!child._hasMargin) return 0 1877: const lead = resolveEdge(child.style.margin, leadingEdge(axis), ownerWidth) 1878: const trail = resolveEdge(child.style.margin, trailingEdge(axis), ownerWidth) 1879: return lead + trail 1880: } 1881: function resolveGap(style: Style, gutter: Gutter, ownerSize: number): number { 1882: let v = style.gap[gutter]! 1883: if (v.unit === Unit.Undefined) v = style.gap[Gutter.All]! 1884: const r = resolveValue(v, ownerSize) 1885: return isDefined(r) ? Math.max(0, r) : 0 1886: } 1887: function boundAxis( 1888: style: Style, 1889: isWidth: boolean, 1890: value: number, 1891: ownerWidth: number, 1892: ownerHeight: number, 1893: ): number { 1894: const minV = isWidth ? style.minWidth : style.minHeight 1895: const maxV = isWidth ? style.maxWidth : style.maxHeight 1896: const minU = minV.unit 1897: const maxU = maxV.unit 1898: if (minU === 0 && maxU === 0) return value 1899: const owner = isWidth ? ownerWidth : ownerHeight 1900: let v = value 1901: if (maxU === 1) { 1902: if (v > maxV.value) v = maxV.value 1903: } else if (maxU === 2) { 1904: const m = (maxV.value * owner) / 100 1905: if (m === m && v > m) v = m 1906: } 1907: if (minU === 1) { 1908: if (v < minV.value) v = minV.value 1909: } else if (minU === 2) { 1910: const m = (minV.value * owner) / 100 1911: if (m === m && v < m) v = m 1912: } 1913: return v 1914: } 1915: function zeroLayoutRecursive(node: Node): void { 1916: for (const c of node.children) { 1917: c.layout.left = 0 1918: c.layout.top = 0 1919: c.layout.width = 0 1920: c.layout.height = 0 1921: c.isDirty_ = true 1922: c._hasL = false 1923: c._hasM = false 1924: zeroLayoutRecursive(c) 1925: } 1926: } 1927: function collectLayoutChildren(node: Node, flow: Node[], abs: Node[]): void { 1928: for (const c of node.children) { 1929: const disp = c.style.display 1930: if (disp === Display.None) { 1931: c.layout.left = 0 1932: c.layout.top = 0 1933: c.layout.width = 0 1934: c.layout.height = 0 1935: zeroLayoutRecursive(c) 1936: } else if (disp === Display.Contents) { 1937: c.layout.left = 0 1938: c.layout.top = 0 1939: c.layout.width = 0 1940: c.layout.height = 0 1941: collectLayoutChildren(c, flow, abs) 1942: } else if (c.style.positionType === PositionType.Absolute) { 1943: abs.push(c) 1944: } else { 1945: flow.push(c) 1946: } 1947: } 1948: } 1949: function roundLayout( 1950: node: Node, 1951: scale: number, 1952: absLeft: number, 1953: absTop: number, 1954: ): void { 1955: if (scale === 0) return 1956: const l = node.layout 1957: const nodeLeft = l.left 1958: const nodeTop = l.top 1959: const nodeWidth = l.width 1960: const nodeHeight = l.height 1961: const absNodeLeft = absLeft + nodeLeft 1962: const absNodeTop = absTop + nodeTop 1963: const isText = node.measureFunc !== null 1964: l.left = roundValue(nodeLeft, scale, false, isText) 1965: l.top = roundValue(nodeTop, scale, false, isText) 1966: const absRight = absNodeLeft + nodeWidth 1967: const absBottom = absNodeTop + nodeHeight 1968: const hasFracW = !isWholeNumber(nodeWidth * scale) 1969: const hasFracH = !isWholeNumber(nodeHeight * scale) 1970: l.width = 1971: roundValue(absRight, scale, isText && hasFracW, isText && !hasFracW) - 1972: roundValue(absNodeLeft, scale, false, isText) 1973: l.height = 1974: roundValue(absBottom, scale, isText && hasFracH, isText && !hasFracH) - 1975: roundValue(absNodeTop, scale, false, isText) 1976: for (const c of node.children) { 1977: roundLayout(c, scale, absNodeLeft, absNodeTop) 1978: } 1979: } 1980: function isWholeNumber(v: number): boolean { 1981: const frac = v - Math.floor(v) 1982: return frac < 0.0001 || frac > 0.9999 1983: } 1984: function roundValue( 1985: v: number, 1986: scale: number, 1987: forceCeil: boolean, 1988: forceFloor: boolean, 1989: ): number { 1990: let scaled = v * scale 1991: let frac = scaled - Math.floor(scaled) 1992: if (frac < 0) frac += 1 1993: if (frac < 0.0001) { 1994: scaled = Math.floor(scaled) 1995: } else if (frac > 0.9999) { 1996: scaled = Math.ceil(scaled) 1997: } else if (forceCeil) { 1998: scaled = Math.ceil(scaled) 1999: } else if (forceFloor) { 2000: scaled = Math.floor(scaled) 2001: } else { 2002: scaled = Math.floor(scaled) + (frac >= 0.4999 ? 1 : 0) 2003: } 2004: return scaled / scale 2005: } 2006: function parseDimension(v: number | string | undefined): Value { 2007: if (v === undefined) return UNDEFINED_VALUE 2008: if (v === 'auto') return AUTO_VALUE 2009: if (typeof v === 'number') { 2010: return Number.isFinite(v) ? pointValue(v) : UNDEFINED_VALUE 2011: } 2012: if (typeof v === 'string' && v.endsWith('%')) { 2013: return percentValue(parseFloat(v)) 2014: } 2015: const n = parseFloat(v) 2016: return isNaN(n) ? UNDEFINED_VALUE : pointValue(n) 2017: } 2018: function physicalEdge(edge: Edge): number { 2019: switch (edge) { 2020: case Edge.Left: 2021: case Edge.Start: 2022: return EDGE_LEFT 2023: case Edge.Top: 2024: return EDGE_TOP 2025: case Edge.Right: 2026: case Edge.End: 2027: return EDGE_RIGHT 2028: case Edge.Bottom: 2029: return EDGE_BOTTOM 2030: default: 2031: return EDGE_LEFT 2032: } 2033: } 2034: export type Yoga = { 2035: Config: { 2036: create(): Config 2037: destroy(config: Config): void 2038: } 2039: Node: { 2040: create(config?: Config): Node 2041: createDefault(): Node 2042: createWithConfig(config: Config): Node 2043: destroy(node: Node): void 2044: } 2045: } 2046: const YOGA_INSTANCE: Yoga = { 2047: Config: { 2048: create: createConfig, 2049: destroy() {}, 2050: }, 2051: Node: { 2052: create: (config?: Config) => new Node(config), 2053: createDefault: () => new Node(), 2054: createWithConfig: (config: Config) => new Node(config), 2055: destroy() {}, 2056: }, 2057: } 2058: export function loadYoga(): Promise<Yoga> { 2059: return Promise.resolve(YOGA_INSTANCE) 2060: } 2061: export default YOGA_INSTANCE

File: src/outputStyles/loadOutputStylesDir.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import { basename } from 'path' 3: import type { OutputStyleConfig } from '../constants/outputStyles.js' 4: import { logForDebugging } from '../utils/debug.js' 5: import { coerceDescriptionToString } from '../utils/frontmatterParser.js' 6: import { logError } from '../utils/log.js' 7: import { 8: extractDescriptionFromMarkdown, 9: loadMarkdownFilesForSubdir, 10: } from '../utils/markdownConfigLoader.js' 11: import { clearPluginOutputStyleCache } from '../utils/plugins/loadPluginOutputStyles.js' 12: export const getOutputStyleDirStyles = memoize( 13: async (cwd: string): Promise<OutputStyleConfig[]> => { 14: try { 15: const markdownFiles = await loadMarkdownFilesForSubdir( 16: 'output-styles', 17: cwd, 18: ) 19: const styles = markdownFiles 20: .map(({ filePath, frontmatter, content, source }) => { 21: try { 22: const fileName = basename(filePath) 23: const styleName = fileName.replace(/\.md$/, '') 24: // Get style configuration from frontmatter 25: const name = (frontmatter['name'] || styleName) as string 26: const description = 27: coerceDescriptionToString( 28: frontmatter['description'], 29: styleName, 30: ) ?? 31: extractDescriptionFromMarkdown( 32: content, 33: `Custom ${styleName} output style`, 34: ) 35: const keepCodingInstructionsRaw = 36: frontmatter['keep-coding-instructions'] 37: const keepCodingInstructions = 38: keepCodingInstructionsRaw === true || 39: keepCodingInstructionsRaw === 'true' 40: ? true 41: : keepCodingInstructionsRaw === false || 42: keepCodingInstructionsRaw === 'false' 43: ? false 44: : undefined 45: if (frontmatter['force-for-plugin'] !== undefined) { 46: logForDebugging( 47: `Output style "${name}" has force-for-plugin set, but this option only applies to plugin output styles. Ignoring.`, 48: { level: 'warn' }, 49: ) 50: } 51: return { 52: name, 53: description, 54: prompt: content.trim(), 55: source, 56: keepCodingInstructions, 57: } 58: } catch (error) { 59: logError(error) 60: return null 61: } 62: }) 63: .filter(style => style !== null) 64: return styles 65: } catch (error) { 66: logError(error) 67: return [] 68: } 69: }, 70: ) 71: export function clearOutputStyleCaches(): void { 72: getOutputStyleDirStyles.cache?.clear?.() 73: loadMarkdownFilesForSubdir.cache?.clear?.() 74: clearPluginOutputStyleCache() 75: }

File: src/plugins/bundled/index.ts

typescript 1: export function initBuiltinPlugins(): void { 2: }

File: src/plugins/builtinPlugins.ts

typescript 1: import type { Command } from '../commands.js' 2: import type { BundledSkillDefinition } from '../skills/bundledSkills.js' 3: import type { BuiltinPluginDefinition, LoadedPlugin } from '../types/plugin.js' 4: import { getSettings_DEPRECATED } from '../utils/settings/settings.js' 5: const BUILTIN_PLUGINS: Map<string, BuiltinPluginDefinition> = new Map() 6: export const BUILTIN_MARKETPLACE_NAME = 'builtin' 7: export function registerBuiltinPlugin( 8: definition: BuiltinPluginDefinition, 9: ): void { 10: BUILTIN_PLUGINS.set(definition.name, definition) 11: } 12: export function isBuiltinPluginId(pluginId: string): boolean { 13: return pluginId.endsWith(`@${BUILTIN_MARKETPLACE_NAME}`) 14: } 15: export function getBuiltinPluginDefinition( 16: name: string, 17: ): BuiltinPluginDefinition | undefined { 18: return BUILTIN_PLUGINS.get(name) 19: } 20: export function getBuiltinPlugins(): { 21: enabled: LoadedPlugin[] 22: disabled: LoadedPlugin[] 23: } { 24: const settings = getSettings_DEPRECATED() 25: const enabled: LoadedPlugin[] = [] 26: const disabled: LoadedPlugin[] = [] 27: for (const [name, definition] of BUILTIN_PLUGINS) { 28: if (definition.isAvailable && !definition.isAvailable()) { 29: continue 30: } 31: const pluginId = `${name}@${BUILTIN_MARKETPLACE_NAME}` 32: const userSetting = settings?.enabledPlugins?.[pluginId] 33: const isEnabled = 34: userSetting !== undefined 35: ? userSetting === true 36: : (definition.defaultEnabled ?? true) 37: const plugin: LoadedPlugin = { 38: name, 39: manifest: { 40: name, 41: description: definition.description, 42: version: definition.version, 43: }, 44: path: BUILTIN_MARKETPLACE_NAME, 45: source: pluginId, 46: repository: pluginId, 47: enabled: isEnabled, 48: isBuiltin: true, 49: hooksConfig: definition.hooks, 50: mcpServers: definition.mcpServers, 51: } 52: if (isEnabled) { 53: enabled.push(plugin) 54: } else { 55: disabled.push(plugin) 56: } 57: } 58: return { enabled, disabled } 59: } 60: export function getBuiltinPluginSkillCommands(): Command[] { 61: const { enabled } = getBuiltinPlugins() 62: const commands: Command[] = [] 63: for (const plugin of enabled) { 64: const definition = BUILTIN_PLUGINS.get(plugin.name) 65: if (!definition?.skills) continue 66: for (const skill of definition.skills) { 67: commands.push(skillDefinitionToCommand(skill)) 68: } 69: } 70: return commands 71: } 72: export function clearBuiltinPlugins(): void { 73: BUILTIN_PLUGINS.clear() 74: } 75: function skillDefinitionToCommand(definition: BundledSkillDefinition): Command { 76: return { 77: type: 'prompt', 78: name: definition.name, 79: description: definition.description, 80: hasUserSpecifiedDescription: true, 81: allowedTools: definition.allowedTools ?? [], 82: argumentHint: definition.argumentHint, 83: whenToUse: definition.whenToUse, 84: model: definition.model, 85: disableModelInvocation: definition.disableModelInvocation ?? false, 86: userInvocable: definition.userInvocable ?? true, 87: contentLength: 0, 88: source: 'bundled', 89: loadedFrom: 'bundled', 90: hooks: definition.hooks, 91: context: definition.context, 92: agent: definition.agent, 93: isEnabled: definition.isEnabled ?? (() => true), 94: isHidden: !(definition.userInvocable ?? true), 95: progressMessage: 'running', 96: getPromptForCommand: definition.getPromptForCommand, 97: } 98: }

File: src/query/config.ts

typescript 1: import { getSessionId } from '../bootstrap/state.js' 2: import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 3: import type { SessionId } from '../types/ids.js' 4: import { isEnvTruthy } from '../utils/envUtils.js' 5: export type QueryConfig = { 6: sessionId: SessionId 7: gates: { 8: streamingToolExecution: boolean 9: emitToolUseSummaries: boolean 10: isAnt: boolean 11: fastModeEnabled: boolean 12: } 13: } 14: export function buildQueryConfig(): QueryConfig { 15: return { 16: sessionId: getSessionId(), 17: gates: { 18: streamingToolExecution: checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 19: 'tengu_streaming_tool_execution2', 20: ), 21: emitToolUseSummaries: isEnvTruthy( 22: process.env.CLAUDE_CODE_EMIT_TOOL_USE_SUMMARIES, 23: ), 24: isAnt: process.env.USER_TYPE === 'ant', 25: fastModeEnabled: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE), 26: }, 27: } 28: }

File: src/query/deps.ts

typescript 1: import { randomUUID } from 'crypto' 2: import { queryModelWithStreaming } from '../services/api/claude.js' 3: import { autoCompactIfNeeded } from '../services/compact/autoCompact.js' 4: import { microcompactMessages } from '../services/compact/microCompact.js' 5: export type QueryDeps = { 6: callModel: typeof queryModelWithStreaming 7: microcompact: typeof microcompactMessages 8: autocompact: typeof autoCompactIfNeeded 9: uuid: () => string 10: } 11: export function productionDeps(): QueryDeps { 12: return { 13: callModel: queryModelWithStreaming, 14: microcompact: microcompactMessages, 15: autocompact: autoCompactIfNeeded, 16: uuid: randomUUID, 17: } 18: }

File: src/query/stopHooks.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' 3: import { isExtractModeActive } from '../memdir/paths.js' 4: import { 5: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6: logEvent, 7: } from '../services/analytics/index.js' 8: import type { ToolUseContext } from '../Tool.js' 9: import type { HookProgress } from '../types/hooks.js' 10: import type { 11: AssistantMessage, 12: Message, 13: RequestStartEvent, 14: StopHookInfo, 15: StreamEvent, 16: TombstoneMessage, 17: ToolUseSummaryMessage, 18: } from '../types/message.js' 19: import { createAttachmentMessage } from '../utils/attachments.js' 20: import { logForDebugging } from '../utils/debug.js' 21: import { errorMessage } from '../utils/errors.js' 22: import type { REPLHookContext } from '../utils/hooks/postSamplingHooks.js' 23: import { 24: executeStopHooks, 25: executeTaskCompletedHooks, 26: executeTeammateIdleHooks, 27: getStopHookMessage, 28: getTaskCompletedHookMessage, 29: getTeammateIdleHookMessage, 30: } from '../utils/hooks.js' 31: import { 32: createStopHookSummaryMessage, 33: createSystemMessage, 34: createUserInterruptionMessage, 35: createUserMessage, 36: } from '../utils/messages.js' 37: import type { SystemPrompt } from '../utils/systemPromptType.js' 38: import { getTaskListId, listTasks } from '../utils/tasks.js' 39: import { getAgentName, getTeamName, isTeammate } from '../utils/teammate.js' 40: const extractMemoriesModule = feature('EXTRACT_MEMORIES') 41: ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) 42: : null 43: const jobClassifierModule = feature('TEMPLATES') 44: ? (require('../jobs/classifier.js') as typeof import('../jobs/classifier.js')) 45: : null 46: import type { QuerySource } from '../constants/querySource.js' 47: import { executeAutoDream } from '../services/autoDream/autoDream.js' 48: import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' 49: import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js' 50: import { 51: createCacheSafeParams, 52: saveCacheSafeParams, 53: } from '../utils/forkedAgent.js' 54: type StopHookResult = { 55: blockingErrors: Message[] 56: preventContinuation: boolean 57: } 58: export async function* handleStopHooks( 59: messagesForQuery: Message[], 60: assistantMessages: AssistantMessage[], 61: systemPrompt: SystemPrompt, 62: userContext: { [k: string]: string }, 63: systemContext: { [k: string]: string }, 64: toolUseContext: ToolUseContext, 65: querySource: QuerySource, 66: stopHookActive?: boolean, 67: ): AsyncGenerator< 68: | StreamEvent 69: | RequestStartEvent 70: | Message 71: | TombstoneMessage 72: | ToolUseSummaryMessage, 73: StopHookResult 74: > { 75: const hookStartTime = Date.now() 76: const stopHookContext: REPLHookContext = { 77: messages: [...messagesForQuery, ...assistantMessages], 78: systemPrompt, 79: userContext, 80: systemContext, 81: toolUseContext, 82: querySource, 83: } 84: if (querySource === 'repl_main_thread' || querySource === 'sdk') { 85: saveCacheSafeParams(createCacheSafeParams(stopHookContext)) 86: } 87: if ( 88: feature('TEMPLATES') && 89: process.env.CLAUDE_JOB_DIR && 90: querySource.startsWith('repl_main_thread') && 91: !toolUseContext.agentId 92: ) { 93: const turnAssistantMessages = stopHookContext.messages.filter( 94: (m): m is AssistantMessage => m.type === 'assistant', 95: ) 96: const p = jobClassifierModule! 97: .classifyAndWriteState(process.env.CLAUDE_JOB_DIR, turnAssistantMessages) 98: .catch(err => { 99: logForDebugging(`[job] classifier error: ${errorMessage(err)}`, { 100: level: 'error', 101: }) 102: }) 103: await Promise.race([ 104: p, 105: new Promise<void>(r => setTimeout(r, 60_000).unref()), 106: ]) 107: } 108: if (!isBareMode()) { 109: if (!isEnvDefinedFalsy(process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION)) { 110: void executePromptSuggestion(stopHookContext) 111: } 112: if ( 113: feature('EXTRACT_MEMORIES') && 114: !toolUseContext.agentId && 115: isExtractModeActive() 116: ) { 117: void extractMemoriesModule!.executeExtractMemories( 118: stopHookContext, 119: toolUseContext.appendSystemMessage, 120: ) 121: } 122: if (!toolUseContext.agentId) { 123: void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage) 124: } 125: } 126: if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { 127: try { 128: const { cleanupComputerUseAfterTurn } = await import( 129: '../utils/computerUse/cleanup.js' 130: ) 131: await cleanupComputerUseAfterTurn(toolUseContext) 132: } catch { 133: } 134: } 135: try { 136: const blockingErrors = [] 137: const appState = toolUseContext.getAppState() 138: const permissionMode = appState.toolPermissionContext.mode 139: const generator = executeStopHooks( 140: permissionMode, 141: toolUseContext.abortController.signal, 142: undefined, 143: stopHookActive ?? false, 144: toolUseContext.agentId, 145: toolUseContext, 146: [...messagesForQuery, ...assistantMessages], 147: toolUseContext.agentType, 148: ) 149: let stopHookToolUseID = '' 150: let hookCount = 0 151: let preventedContinuation = false 152: let stopReason = '' 153: let hasOutput = false 154: const hookErrors: string[] = [] 155: const hookInfos: StopHookInfo[] = [] 156: for await (const result of generator) { 157: if (result.message) { 158: yield result.message 159: // Track toolUseID from progress messages and count hooks 160: if (result.message.type === 'progress' && result.message.toolUseID) { 161: stopHookToolUseID = result.message.toolUseID 162: hookCount++ 163: const progressData = result.message.data as HookProgress 164: if (progressData.command) { 165: hookInfos.push({ 166: command: progressData.command, 167: promptText: progressData.promptText, 168: }) 169: } 170: } 171: if (result.message.type === 'attachment') { 172: const attachment = result.message.attachment 173: if ( 174: 'hookEvent' in attachment && 175: (attachment.hookEvent === 'Stop' || 176: attachment.hookEvent === 'SubagentStop') 177: ) { 178: if (attachment.type === 'hook_non_blocking_error') { 179: hookErrors.push( 180: attachment.stderr || `Exit code ${attachment.exitCode}`, 181: ) 182: hasOutput = true 183: } else if (attachment.type === 'hook_error_during_execution') { 184: hookErrors.push(attachment.content) 185: hasOutput = true 186: } else if (attachment.type === 'hook_success') { 187: if ( 188: (attachment.stdout && attachment.stdout.trim()) || 189: (attachment.stderr && attachment.stderr.trim()) 190: ) { 191: hasOutput = true 192: } 193: } 194: if ('durationMs' in attachment && 'command' in attachment) { 195: const info = hookInfos.find( 196: i => 197: i.command === attachment.command && 198: i.durationMs === undefined, 199: ) 200: if (info) { 201: info.durationMs = attachment.durationMs 202: } 203: } 204: } 205: } 206: } 207: if (result.blockingError) { 208: const userMessage = createUserMessage({ 209: content: getStopHookMessage(result.blockingError), 210: isMeta: true, 211: }) 212: blockingErrors.push(userMessage) 213: yield userMessage 214: hasOutput = true 215: hookErrors.push(result.blockingError.blockingError) 216: } 217: if (result.preventContinuation) { 218: preventedContinuation = true 219: stopReason = result.stopReason || 'Stop hook prevented continuation' 220: yield createAttachmentMessage({ 221: type: 'hook_stopped_continuation', 222: message: stopReason, 223: hookName: 'Stop', 224: toolUseID: stopHookToolUseID, 225: hookEvent: 'Stop', 226: }) 227: } 228: if (toolUseContext.abortController.signal.aborted) { 229: logEvent('tengu_pre_stop_hooks_cancelled', { 230: queryChainId: toolUseContext.queryTracking 231: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 232: queryDepth: toolUseContext.queryTracking?.depth, 233: }) 234: yield createUserInterruptionMessage({ 235: toolUse: false, 236: }) 237: return { blockingErrors: [], preventContinuation: true } 238: } 239: } 240: if (hookCount > 0) { 241: yield createStopHookSummaryMessage( 242: hookCount, 243: hookInfos, 244: hookErrors, 245: preventedContinuation, 246: stopReason, 247: hasOutput, 248: 'suggestion', 249: stopHookToolUseID, 250: ) 251: if (hookErrors.length > 0) { 252: const expandShortcut = getShortcutDisplay( 253: 'app:toggleTranscript', 254: 'Global', 255: 'ctrl+o', 256: ) 257: toolUseContext.addNotification?.({ 258: key: 'stop-hook-error', 259: text: `Stop hook error occurred \u00b7 ${expandShortcut} to see`, 260: priority: 'immediate', 261: }) 262: } 263: } 264: if (preventedContinuation) { 265: return { blockingErrors: [], preventContinuation: true } 266: } 267: if (blockingErrors.length > 0) { 268: return { blockingErrors, preventContinuation: false } 269: } 270: if (isTeammate()) { 271: const teammateName = getAgentName() ?? '' 272: const teamName = getTeamName() ?? '' 273: const teammateBlockingErrors: Message[] = [] 274: let teammatePreventedContinuation = false 275: let teammateStopReason: string | undefined 276: // Each hook executor generates its own toolUseID — capture from progress 277: // messages (same pattern as stopHookToolUseID at L142), not the Stop ID. 278: let teammateHookToolUseID = '' 279: // Run TaskCompleted hooks for any in-progress tasks owned by this teammate 280: const taskListId = getTaskListId() 281: const tasks = await listTasks(taskListId) 282: const inProgressTasks = tasks.filter( 283: t => t.status === 'in_progress' && t.owner === teammateName, 284: ) 285: for (const task of inProgressTasks) { 286: const taskCompletedGenerator = executeTaskCompletedHooks( 287: task.id, 288: task.subject, 289: task.description, 290: teammateName, 291: teamName, 292: permissionMode, 293: toolUseContext.abortController.signal, 294: undefined, 295: toolUseContext, 296: ) 297: for await (const result of taskCompletedGenerator) { 298: if (result.message) { 299: if ( 300: result.message.type === 'progress' && 301: result.message.toolUseID 302: ) { 303: teammateHookToolUseID = result.message.toolUseID 304: } 305: yield result.message 306: } 307: if (result.blockingError) { 308: const userMessage = createUserMessage({ 309: content: getTaskCompletedHookMessage(result.blockingError), 310: isMeta: true, 311: }) 312: teammateBlockingErrors.push(userMessage) 313: yield userMessage 314: } 315: if (result.preventContinuation) { 316: teammatePreventedContinuation = true 317: teammateStopReason = 318: result.stopReason || 'TaskCompleted hook prevented continuation' 319: yield createAttachmentMessage({ 320: type: 'hook_stopped_continuation', 321: message: teammateStopReason, 322: hookName: 'TaskCompleted', 323: toolUseID: teammateHookToolUseID, 324: hookEvent: 'TaskCompleted', 325: }) 326: } 327: if (toolUseContext.abortController.signal.aborted) { 328: return { blockingErrors: [], preventContinuation: true } 329: } 330: } 331: } 332: const teammateIdleGenerator = executeTeammateIdleHooks( 333: teammateName, 334: teamName, 335: permissionMode, 336: toolUseContext.abortController.signal, 337: ) 338: for await (const result of teammateIdleGenerator) { 339: if (result.message) { 340: if (result.message.type === 'progress' && result.message.toolUseID) { 341: teammateHookToolUseID = result.message.toolUseID 342: } 343: yield result.message 344: } 345: if (result.blockingError) { 346: const userMessage = createUserMessage({ 347: content: getTeammateIdleHookMessage(result.blockingError), 348: isMeta: true, 349: }) 350: teammateBlockingErrors.push(userMessage) 351: yield userMessage 352: } 353: if (result.preventContinuation) { 354: teammatePreventedContinuation = true 355: teammateStopReason = 356: result.stopReason || 'TeammateIdle hook prevented continuation' 357: yield createAttachmentMessage({ 358: type: 'hook_stopped_continuation', 359: message: teammateStopReason, 360: hookName: 'TeammateIdle', 361: toolUseID: teammateHookToolUseID, 362: hookEvent: 'TeammateIdle', 363: }) 364: } 365: if (toolUseContext.abortController.signal.aborted) { 366: return { blockingErrors: [], preventContinuation: true } 367: } 368: } 369: if (teammatePreventedContinuation) { 370: return { blockingErrors: [], preventContinuation: true } 371: } 372: if (teammateBlockingErrors.length > 0) { 373: return { 374: blockingErrors: teammateBlockingErrors, 375: preventContinuation: false, 376: } 377: } 378: } 379: return { blockingErrors: [], preventContinuation: false } 380: } catch (error) { 381: const durationMs = Date.now() - hookStartTime 382: logEvent('tengu_stop_hook_error', { 383: duration: durationMs, 384: queryChainId: toolUseContext.queryTracking 385: ?.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 386: queryDepth: toolUseContext.queryTracking?.depth, 387: }) 388: yield createSystemMessage( 389: `Stop hook failed: ${errorMessage(error)}`, 390: 'warning', 391: ) 392: return { blockingErrors: [], preventContinuation: false } 393: } 394: }

File: src/query/tokenBudget.ts

typescript 1: import { getBudgetContinuationMessage } from '../utils/tokenBudget.js' 2: const COMPLETION_THRESHOLD = 0.9 3: const DIMINISHING_THRESHOLD = 500 4: export type BudgetTracker = { 5: continuationCount: number 6: lastDeltaTokens: number 7: lastGlobalTurnTokens: number 8: startedAt: number 9: } 10: export function createBudgetTracker(): BudgetTracker { 11: return { 12: continuationCount: 0, 13: lastDeltaTokens: 0, 14: lastGlobalTurnTokens: 0, 15: startedAt: Date.now(), 16: } 17: } 18: type ContinueDecision = { 19: action: 'continue' 20: nudgeMessage: string 21: continuationCount: number 22: pct: number 23: turnTokens: number 24: budget: number 25: } 26: type StopDecision = { 27: action: 'stop' 28: completionEvent: { 29: continuationCount: number 30: pct: number 31: turnTokens: number 32: budget: number 33: diminishingReturns: boolean 34: durationMs: number 35: } | null 36: } 37: export type TokenBudgetDecision = ContinueDecision | StopDecision 38: export function checkTokenBudget( 39: tracker: BudgetTracker, 40: agentId: string | undefined, 41: budget: number | null, 42: globalTurnTokens: number, 43: ): TokenBudgetDecision { 44: if (agentId || budget === null || budget <= 0) { 45: return { action: 'stop', completionEvent: null } 46: } 47: const turnTokens = globalTurnTokens 48: const pct = Math.round((turnTokens / budget) * 100) 49: const deltaSinceLastCheck = globalTurnTokens - tracker.lastGlobalTurnTokens 50: const isDiminishing = 51: tracker.continuationCount >= 3 && 52: deltaSinceLastCheck < DIMINISHING_THRESHOLD && 53: tracker.lastDeltaTokens < DIMINISHING_THRESHOLD 54: if (!isDiminishing && turnTokens < budget * COMPLETION_THRESHOLD) { 55: tracker.continuationCount++ 56: tracker.lastDeltaTokens = deltaSinceLastCheck 57: tracker.lastGlobalTurnTokens = globalTurnTokens 58: return { 59: action: 'continue', 60: nudgeMessage: getBudgetContinuationMessage(pct, turnTokens, budget), 61: continuationCount: tracker.continuationCount, 62: pct, 63: turnTokens, 64: budget, 65: } 66: } 67: if (isDiminishing || tracker.continuationCount > 0) { 68: return { 69: action: 'stop', 70: completionEvent: { 71: continuationCount: tracker.continuationCount, 72: pct, 73: turnTokens, 74: budget, 75: diminishingReturns: isDiminishing, 76: durationMs: Date.now() - tracker.startedAt, 77: }, 78: } 79: } 80: return { action: 'stop', completionEvent: null } 81: }

File: src/remote/remotePermissionBridge.ts

typescript 1: import { randomUUID } from 'crypto' 2: import type { SDKControlPermissionRequest } from '../entrypoints/sdk/controlTypes.js' 3: import type { Tool } from '../Tool.js' 4: import type { AssistantMessage } from '../types/message.js' 5: import { jsonStringify } from '../utils/slowOperations.js' 6: export function createSyntheticAssistantMessage( 7: request: SDKControlPermissionRequest, 8: requestId: string, 9: ): AssistantMessage { 10: return { 11: type: 'assistant', 12: uuid: randomUUID(), 13: message: { 14: id: `remote-${requestId}`, 15: type: 'message', 16: role: 'assistant', 17: content: [ 18: { 19: type: 'tool_use', 20: id: request.tool_use_id, 21: name: request.tool_name, 22: input: request.input, 23: }, 24: ], 25: model: '', 26: stop_reason: null, 27: stop_sequence: null, 28: container: null, 29: context_management: null, 30: usage: { 31: input_tokens: 0, 32: output_tokens: 0, 33: cache_creation_input_tokens: 0, 34: cache_read_input_tokens: 0, 35: }, 36: } as AssistantMessage['message'], 37: requestId: undefined, 38: timestamp: new Date().toISOString(), 39: } 40: } 41: export function createToolStub(toolName: string): Tool { 42: return { 43: name: toolName, 44: inputSchema: {} as Tool['inputSchema'], 45: isEnabled: () => true, 46: userFacingName: () => toolName, 47: renderToolUseMessage: (input: Record<string, unknown>) => { 48: const entries = Object.entries(input) 49: if (entries.length === 0) return '' 50: return entries 51: .slice(0, 3) 52: .map(([key, value]) => { 53: const valueStr = 54: typeof value === 'string' ? value : jsonStringify(value) 55: return `${key}: ${valueStr}` 56: }) 57: .join(', ') 58: }, 59: call: async () => ({ data: '' }), 60: description: async () => '', 61: prompt: () => '', 62: isReadOnly: () => false, 63: isMcp: false, 64: needsPermissions: () => true, 65: } as unknown as Tool 66: }

File: src/remote/RemoteSessionManager.ts

typescript 1: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 2: import type { 3: SDKControlCancelRequest, 4: SDKControlPermissionRequest, 5: SDKControlRequest, 6: SDKControlResponse, 7: } from '../entrypoints/sdk/controlTypes.js' 8: import { logForDebugging } from '../utils/debug.js' 9: import { logError } from '../utils/log.js' 10: import { 11: type RemoteMessageContent, 12: sendEventToRemoteSession, 13: } from '../utils/teleport/api.js' 14: import { 15: SessionsWebSocket, 16: type SessionsWebSocketCallbacks, 17: } from './SessionsWebSocket.js' 18: function isSDKMessage( 19: message: 20: | SDKMessage 21: | SDKControlRequest 22: | SDKControlResponse 23: | SDKControlCancelRequest, 24: ): message is SDKMessage { 25: return ( 26: message.type !== 'control_request' && 27: message.type !== 'control_response' && 28: message.type !== 'control_cancel_request' 29: ) 30: } 31: export type RemotePermissionResponse = 32: | { 33: behavior: 'allow' 34: updatedInput: Record<string, unknown> 35: } 36: | { 37: behavior: 'deny' 38: message: string 39: } 40: export type RemoteSessionConfig = { 41: sessionId: string 42: getAccessToken: () => string 43: orgUuid: string 44: hasInitialPrompt?: boolean 45: viewerOnly?: boolean 46: } 47: export type RemoteSessionCallbacks = { 48: onMessage: (message: SDKMessage) => void 49: onPermissionRequest: ( 50: request: SDKControlPermissionRequest, 51: requestId: string, 52: ) => void 53: onPermissionCancelled?: ( 54: requestId: string, 55: toolUseId: string | undefined, 56: ) => void 57: onConnected?: () => void 58: onDisconnected?: () => void 59: onReconnecting?: () => void 60: onError?: (error: Error) => void 61: } 62: export class RemoteSessionManager { 63: private websocket: SessionsWebSocket | null = null 64: private pendingPermissionRequests: Map<string, SDKControlPermissionRequest> = 65: new Map() 66: constructor( 67: private readonly config: RemoteSessionConfig, 68: private readonly callbacks: RemoteSessionCallbacks, 69: ) {} 70: connect(): void { 71: logForDebugging( 72: `[RemoteSessionManager] Connecting to session ${this.config.sessionId}`, 73: ) 74: const wsCallbacks: SessionsWebSocketCallbacks = { 75: onMessage: message => this.handleMessage(message), 76: onConnected: () => { 77: logForDebugging('[RemoteSessionManager] Connected') 78: this.callbacks.onConnected?.() 79: }, 80: onClose: () => { 81: logForDebugging('[RemoteSessionManager] Disconnected') 82: this.callbacks.onDisconnected?.() 83: }, 84: onReconnecting: () => { 85: logForDebugging('[RemoteSessionManager] Reconnecting') 86: this.callbacks.onReconnecting?.() 87: }, 88: onError: error => { 89: logError(error) 90: this.callbacks.onError?.(error) 91: }, 92: } 93: this.websocket = new SessionsWebSocket( 94: this.config.sessionId, 95: this.config.orgUuid, 96: this.config.getAccessToken, 97: wsCallbacks, 98: ) 99: void this.websocket.connect() 100: } 101: private handleMessage( 102: message: 103: | SDKMessage 104: | SDKControlRequest 105: | SDKControlResponse 106: | SDKControlCancelRequest, 107: ): void { 108: if (message.type === 'control_request') { 109: this.handleControlRequest(message) 110: return 111: } 112: if (message.type === 'control_cancel_request') { 113: const { request_id } = message 114: const pendingRequest = this.pendingPermissionRequests.get(request_id) 115: logForDebugging( 116: `[RemoteSessionManager] Permission request cancelled: ${request_id}`, 117: ) 118: this.pendingPermissionRequests.delete(request_id) 119: this.callbacks.onPermissionCancelled?.( 120: request_id, 121: pendingRequest?.tool_use_id, 122: ) 123: return 124: } 125: if (message.type === 'control_response') { 126: logForDebugging('[RemoteSessionManager] Received control response') 127: return 128: } 129: if (isSDKMessage(message)) { 130: this.callbacks.onMessage(message) 131: } 132: } 133: private handleControlRequest(request: SDKControlRequest): void { 134: const { request_id, request: inner } = request 135: if (inner.subtype === 'can_use_tool') { 136: logForDebugging( 137: `[RemoteSessionManager] Permission request for tool: ${inner.tool_name}`, 138: ) 139: this.pendingPermissionRequests.set(request_id, inner) 140: this.callbacks.onPermissionRequest(inner, request_id) 141: } else { 142: logForDebugging( 143: `[RemoteSessionManager] Unsupported control request subtype: ${inner.subtype}`, 144: ) 145: const response: SDKControlResponse = { 146: type: 'control_response', 147: response: { 148: subtype: 'error', 149: request_id, 150: error: `Unsupported control request subtype: ${inner.subtype}`, 151: }, 152: } 153: this.websocket?.sendControlResponse(response) 154: } 155: } 156: async sendMessage( 157: content: RemoteMessageContent, 158: opts?: { uuid?: string }, 159: ): Promise<boolean> { 160: logForDebugging( 161: `[RemoteSessionManager] Sending message to session ${this.config.sessionId}`, 162: ) 163: const success = await sendEventToRemoteSession( 164: this.config.sessionId, 165: content, 166: opts, 167: ) 168: if (!success) { 169: logError( 170: new Error( 171: `[RemoteSessionManager] Failed to send message to session ${this.config.sessionId}`, 172: ), 173: ) 174: } 175: return success 176: } 177: respondToPermissionRequest( 178: requestId: string, 179: result: RemotePermissionResponse, 180: ): void { 181: const pendingRequest = this.pendingPermissionRequests.get(requestId) 182: if (!pendingRequest) { 183: logError( 184: new Error( 185: `[RemoteSessionManager] No pending permission request with ID: ${requestId}`, 186: ), 187: ) 188: return 189: } 190: this.pendingPermissionRequests.delete(requestId) 191: const response: SDKControlResponse = { 192: type: 'control_response', 193: response: { 194: subtype: 'success', 195: request_id: requestId, 196: response: { 197: behavior: result.behavior, 198: ...(result.behavior === 'allow' 199: ? { updatedInput: result.updatedInput } 200: : { message: result.message }), 201: }, 202: }, 203: } 204: logForDebugging( 205: `[RemoteSessionManager] Sending permission response: ${result.behavior}`, 206: ) 207: this.websocket?.sendControlResponse(response) 208: } 209: isConnected(): boolean { 210: return this.websocket?.isConnected() ?? false 211: } 212: cancelSession(): void { 213: logForDebugging('[RemoteSessionManager] Sending interrupt signal') 214: this.websocket?.sendControlRequest({ subtype: 'interrupt' }) 215: } 216: getSessionId(): string { 217: return this.config.sessionId 218: } 219: disconnect(): void { 220: logForDebugging('[RemoteSessionManager] Disconnecting') 221: this.websocket?.close() 222: this.websocket = null 223: this.pendingPermissionRequests.clear() 224: } 225: reconnect(): void { 226: logForDebugging('[RemoteSessionManager] Reconnecting WebSocket') 227: this.websocket?.reconnect() 228: } 229: } 230: export function createRemoteSessionConfig( 231: sessionId: string, 232: getAccessToken: () => string, 233: orgUuid: string, 234: hasInitialPrompt = false, 235: viewerOnly = false, 236: ): RemoteSessionConfig { 237: return { 238: sessionId, 239: getAccessToken, 240: orgUuid, 241: hasInitialPrompt, 242: viewerOnly, 243: } 244: }

File: src/remote/sdkMessageAdapter.ts

typescript 1: import type { 2: SDKAssistantMessage, 3: SDKCompactBoundaryMessage, 4: SDKMessage, 5: SDKPartialAssistantMessage, 6: SDKResultMessage, 7: SDKStatusMessage, 8: SDKSystemMessage, 9: SDKToolProgressMessage, 10: } from '../entrypoints/agentSdkTypes.js' 11: import type { 12: AssistantMessage, 13: Message, 14: StreamEvent, 15: SystemMessage, 16: } from '../types/message.js' 17: import { logForDebugging } from '../utils/debug.js' 18: import { fromSDKCompactMetadata } from '../utils/messages/mappers.js' 19: import { createUserMessage } from '../utils/messages.js' 20: function convertAssistantMessage(msg: SDKAssistantMessage): AssistantMessage { 21: return { 22: type: 'assistant', 23: message: msg.message, 24: uuid: msg.uuid, 25: requestId: undefined, 26: timestamp: new Date().toISOString(), 27: error: msg.error, 28: } 29: } 30: function convertStreamEvent(msg: SDKPartialAssistantMessage): StreamEvent { 31: return { 32: type: 'stream_event', 33: event: msg.event, 34: } 35: } 36: function convertResultMessage(msg: SDKResultMessage): SystemMessage { 37: const isError = msg.subtype !== 'success' 38: const content = isError 39: ? msg.errors?.join(', ') || 'Unknown error' 40: : 'Session completed successfully' 41: return { 42: type: 'system', 43: subtype: 'informational', 44: content, 45: level: isError ? 'warning' : 'info', 46: uuid: msg.uuid, 47: timestamp: new Date().toISOString(), 48: } 49: } 50: function convertInitMessage(msg: SDKSystemMessage): SystemMessage { 51: return { 52: type: 'system', 53: subtype: 'informational', 54: content: `Remote session initialized (model: ${msg.model})`, 55: level: 'info', 56: uuid: msg.uuid, 57: timestamp: new Date().toISOString(), 58: } 59: } 60: function convertStatusMessage(msg: SDKStatusMessage): SystemMessage | null { 61: if (!msg.status) { 62: return null 63: } 64: return { 65: type: 'system', 66: subtype: 'informational', 67: content: 68: msg.status === 'compacting' 69: ? 'Compacting conversation…' 70: : `Status: ${msg.status}`, 71: level: 'info', 72: uuid: msg.uuid, 73: timestamp: new Date().toISOString(), 74: } 75: } 76: function convertToolProgressMessage( 77: msg: SDKToolProgressMessage, 78: ): SystemMessage { 79: return { 80: type: 'system', 81: subtype: 'informational', 82: content: `Tool ${msg.tool_name} running for ${msg.elapsed_time_seconds}s…`, 83: level: 'info', 84: uuid: msg.uuid, 85: timestamp: new Date().toISOString(), 86: toolUseID: msg.tool_use_id, 87: } 88: } 89: function convertCompactBoundaryMessage( 90: msg: SDKCompactBoundaryMessage, 91: ): SystemMessage { 92: return { 93: type: 'system', 94: subtype: 'compact_boundary', 95: content: 'Conversation compacted', 96: level: 'info', 97: uuid: msg.uuid, 98: timestamp: new Date().toISOString(), 99: compactMetadata: fromSDKCompactMetadata(msg.compact_metadata), 100: } 101: } 102: export type ConvertedMessage = 103: | { type: 'message'; message: Message } 104: | { type: 'stream_event'; event: StreamEvent } 105: | { type: 'ignored' } 106: type ConvertOptions = { 107: convertToolResults?: boolean 108: convertUserTextMessages?: boolean 109: } 110: export function convertSDKMessage( 111: msg: SDKMessage, 112: opts?: ConvertOptions, 113: ): ConvertedMessage { 114: switch (msg.type) { 115: case 'assistant': 116: return { type: 'message', message: convertAssistantMessage(msg) } 117: case 'user': { 118: const content = msg.message?.content 119: const isToolResult = 120: Array.isArray(content) && content.some(b => b.type === 'tool_result') 121: if (opts?.convertToolResults && isToolResult) { 122: return { 123: type: 'message', 124: message: createUserMessage({ 125: content, 126: toolUseResult: msg.tool_use_result, 127: uuid: msg.uuid, 128: timestamp: msg.timestamp, 129: }), 130: } 131: } 132: if (opts?.convertUserTextMessages && !isToolResult) { 133: if (typeof content === 'string' || Array.isArray(content)) { 134: return { 135: type: 'message', 136: message: createUserMessage({ 137: content, 138: toolUseResult: msg.tool_use_result, 139: uuid: msg.uuid, 140: timestamp: msg.timestamp, 141: }), 142: } 143: } 144: } 145: return { type: 'ignored' } 146: } 147: case 'stream_event': 148: return { type: 'stream_event', event: convertStreamEvent(msg) } 149: case 'result': 150: if (msg.subtype !== 'success') { 151: return { type: 'message', message: convertResultMessage(msg) } 152: } 153: return { type: 'ignored' } 154: case 'system': 155: if (msg.subtype === 'init') { 156: return { type: 'message', message: convertInitMessage(msg) } 157: } 158: if (msg.subtype === 'status') { 159: const statusMsg = convertStatusMessage(msg) 160: return statusMsg 161: ? { type: 'message', message: statusMsg } 162: : { type: 'ignored' } 163: } 164: if (msg.subtype === 'compact_boundary') { 165: return { 166: type: 'message', 167: message: convertCompactBoundaryMessage(msg), 168: } 169: } 170: logForDebugging( 171: `[sdkMessageAdapter] Ignoring system message subtype: ${msg.subtype}`, 172: ) 173: return { type: 'ignored' } 174: case 'tool_progress': 175: return { type: 'message', message: convertToolProgressMessage(msg) } 176: case 'auth_status': 177: logForDebugging('[sdkMessageAdapter] Ignoring auth_status message') 178: return { type: 'ignored' } 179: case 'tool_use_summary': 180: logForDebugging('[sdkMessageAdapter] Ignoring tool_use_summary message') 181: return { type: 'ignored' } 182: case 'rate_limit_event': 183: logForDebugging('[sdkMessageAdapter] Ignoring rate_limit_event message') 184: return { type: 'ignored' } 185: default: { 186: logForDebugging( 187: `[sdkMessageAdapter] Unknown message type: ${(msg as { type: string }).type}`, 188: ) 189: return { type: 'ignored' } 190: } 191: } 192: } 193: export function isSessionEndMessage(msg: SDKMessage): boolean { 194: return msg.type === 'result' 195: } 196: export function isSuccessResult(msg: SDKResultMessage): boolean { 197: return msg.subtype === 'success' 198: } 199: export function getResultText(msg: SDKResultMessage): string | null { 200: if (msg.subtype === 'success') { 201: return msg.result 202: } 203: return null 204: }

File: src/remote/SessionsWebSocket.ts

typescript 1: import { randomUUID } from 'crypto' 2: import { getOauthConfig } from '../constants/oauth.js' 3: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 4: import type { 5: SDKControlCancelRequest, 6: SDKControlRequest, 7: SDKControlRequestInner, 8: SDKControlResponse, 9: } from '../entrypoints/sdk/controlTypes.js' 10: import { logForDebugging } from '../utils/debug.js' 11: import { errorMessage } from '../utils/errors.js' 12: import { logError } from '../utils/log.js' 13: import { getWebSocketTLSOptions } from '../utils/mtls.js' 14: import { getWebSocketProxyAgent, getWebSocketProxyUrl } from '../utils/proxy.js' 15: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 16: const RECONNECT_DELAY_MS = 2000 17: const MAX_RECONNECT_ATTEMPTS = 5 18: const PING_INTERVAL_MS = 30000 19: const MAX_SESSION_NOT_FOUND_RETRIES = 3 20: const PERMANENT_CLOSE_CODES = new Set([ 21: 4003, 22: ]) 23: type WebSocketState = 'connecting' | 'connected' | 'closed' 24: type SessionsMessage = 25: | SDKMessage 26: | SDKControlRequest 27: | SDKControlResponse 28: | SDKControlCancelRequest 29: function isSessionsMessage(value: unknown): value is SessionsMessage { 30: if (typeof value !== 'object' || value === null || !('type' in value)) { 31: return false 32: } 33: return typeof value.type === 'string' 34: } 35: export type SessionsWebSocketCallbacks = { 36: onMessage: (message: SessionsMessage) => void 37: onClose?: () => void 38: onError?: (error: Error) => void 39: onConnected?: () => void 40: onReconnecting?: () => void 41: } 42: type WebSocketLike = { 43: close(): void 44: send(data: string): void 45: ping?(): void 46: } 47: export class SessionsWebSocket { 48: private ws: WebSocketLike | null = null 49: private state: WebSocketState = 'closed' 50: private reconnectAttempts = 0 51: private sessionNotFoundRetries = 0 52: private pingInterval: NodeJS.Timeout | null = null 53: private reconnectTimer: NodeJS.Timeout | null = null 54: constructor( 55: private readonly sessionId: string, 56: private readonly orgUuid: string, 57: private readonly getAccessToken: () => string, 58: private readonly callbacks: SessionsWebSocketCallbacks, 59: ) {} 60: async connect(): Promise<void> { 61: if (this.state === 'connecting') { 62: logForDebugging('[SessionsWebSocket] Already connecting') 63: return 64: } 65: this.state = 'connecting' 66: const baseUrl = getOauthConfig().BASE_API_URL.replace('https://', 'wss://') 67: const url = `${baseUrl}/v1/sessions/ws/${this.sessionId}/subscribe?organization_uuid=${this.orgUuid}` 68: logForDebugging(`[SessionsWebSocket] Connecting to ${url}`) 69: const accessToken = this.getAccessToken() 70: const headers = { 71: Authorization: `Bearer ${accessToken}`, 72: 'anthropic-version': '2023-06-01', 73: } 74: if (typeof Bun !== 'undefined') { 75: const ws = new globalThis.WebSocket(url, { 76: headers, 77: proxy: getWebSocketProxyUrl(url), 78: tls: getWebSocketTLSOptions() || undefined, 79: } as unknown as string[]) 80: this.ws = ws 81: ws.addEventListener('open', () => { 82: logForDebugging( 83: '[SessionsWebSocket] Connection opened, authenticated via headers', 84: ) 85: this.state = 'connected' 86: this.reconnectAttempts = 0 87: this.sessionNotFoundRetries = 0 88: this.startPingInterval() 89: this.callbacks.onConnected?.() 90: }) 91: ws.addEventListener('message', (event: MessageEvent) => { 92: const data = 93: typeof event.data === 'string' ? event.data : String(event.data) 94: this.handleMessage(data) 95: }) 96: ws.addEventListener('error', () => { 97: const err = new Error('[SessionsWebSocket] WebSocket error') 98: logError(err) 99: this.callbacks.onError?.(err) 100: }) 101: ws.addEventListener('close', (event: CloseEvent) => { 102: logForDebugging( 103: `[SessionsWebSocket] Closed: code=${event.code} reason=${event.reason}`, 104: ) 105: this.handleClose(event.code) 106: }) 107: ws.addEventListener('pong', () => { 108: logForDebugging('[SessionsWebSocket] Pong received') 109: }) 110: } else { 111: const { default: WS } = await import('ws') 112: const ws = new WS(url, { 113: headers, 114: agent: getWebSocketProxyAgent(url), 115: ...getWebSocketTLSOptions(), 116: }) 117: this.ws = ws 118: ws.on('open', () => { 119: logForDebugging( 120: '[SessionsWebSocket] Connection opened, authenticated via headers', 121: ) 122: this.state = 'connected' 123: this.reconnectAttempts = 0 124: this.sessionNotFoundRetries = 0 125: this.startPingInterval() 126: this.callbacks.onConnected?.() 127: }) 128: ws.on('message', (data: Buffer) => { 129: this.handleMessage(data.toString()) 130: }) 131: ws.on('error', (err: Error) => { 132: logError(new Error(`[SessionsWebSocket] Error: ${err.message}`)) 133: this.callbacks.onError?.(err) 134: }) 135: ws.on('close', (code: number, reason: Buffer) => { 136: logForDebugging( 137: `[SessionsWebSocket] Closed: code=${code} reason=${reason.toString()}`, 138: ) 139: this.handleClose(code) 140: }) 141: ws.on('pong', () => { 142: logForDebugging('[SessionsWebSocket] Pong received') 143: }) 144: } 145: } 146: private handleMessage(data: string): void { 147: try { 148: const message: unknown = jsonParse(data) 149: if (isSessionsMessage(message)) { 150: this.callbacks.onMessage(message) 151: } else { 152: logForDebugging( 153: `[SessionsWebSocket] Ignoring message type: ${typeof message === 'object' && message !== null && 'type' in message ? String(message.type) : 'unknown'}`, 154: ) 155: } 156: } catch (error) { 157: logError( 158: new Error( 159: `[SessionsWebSocket] Failed to parse message: ${errorMessage(error)}`, 160: ), 161: ) 162: } 163: } 164: private handleClose(closeCode: number): void { 165: this.stopPingInterval() 166: if (this.state === 'closed') { 167: return 168: } 169: this.ws = null 170: const previousState = this.state 171: this.state = 'closed' 172: if (PERMANENT_CLOSE_CODES.has(closeCode)) { 173: logForDebugging( 174: `[SessionsWebSocket] Permanent close code ${closeCode}, not reconnecting`, 175: ) 176: this.callbacks.onClose?.() 177: return 178: } 179: if (closeCode === 4001) { 180: this.sessionNotFoundRetries++ 181: if (this.sessionNotFoundRetries > MAX_SESSION_NOT_FOUND_RETRIES) { 182: logForDebugging( 183: `[SessionsWebSocket] 4001 retry budget exhausted (${MAX_SESSION_NOT_FOUND_RETRIES}), not reconnecting`, 184: ) 185: this.callbacks.onClose?.() 186: return 187: } 188: this.scheduleReconnect( 189: RECONNECT_DELAY_MS * this.sessionNotFoundRetries, 190: `4001 attempt ${this.sessionNotFoundRetries}/${MAX_SESSION_NOT_FOUND_RETRIES}`, 191: ) 192: return 193: } 194: if ( 195: previousState === 'connected' && 196: this.reconnectAttempts < MAX_RECONNECT_ATTEMPTS 197: ) { 198: this.reconnectAttempts++ 199: this.scheduleReconnect( 200: RECONNECT_DELAY_MS, 201: `attempt ${this.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`, 202: ) 203: } else { 204: logForDebugging('[SessionsWebSocket] Not reconnecting') 205: this.callbacks.onClose?.() 206: } 207: } 208: private scheduleReconnect(delay: number, label: string): void { 209: this.callbacks.onReconnecting?.() 210: logForDebugging( 211: `[SessionsWebSocket] Scheduling reconnect (${label}) in ${delay}ms`, 212: ) 213: this.reconnectTimer = setTimeout(() => { 214: this.reconnectTimer = null 215: void this.connect() 216: }, delay) 217: } 218: private startPingInterval(): void { 219: this.stopPingInterval() 220: this.pingInterval = setInterval(() => { 221: if (this.ws && this.state === 'connected') { 222: try { 223: this.ws.ping?.() 224: } catch { 225: } 226: } 227: }, PING_INTERVAL_MS) 228: } 229: private stopPingInterval(): void { 230: if (this.pingInterval) { 231: clearInterval(this.pingInterval) 232: this.pingInterval = null 233: } 234: } 235: sendControlResponse(response: SDKControlResponse): void { 236: if (!this.ws || this.state !== 'connected') { 237: logError(new Error('[SessionsWebSocket] Cannot send: not connected')) 238: return 239: } 240: logForDebugging('[SessionsWebSocket] Sending control response') 241: this.ws.send(jsonStringify(response)) 242: } 243: sendControlRequest(request: SDKControlRequestInner): void { 244: if (!this.ws || this.state !== 'connected') { 245: logError(new Error('[SessionsWebSocket] Cannot send: not connected')) 246: return 247: } 248: const controlRequest: SDKControlRequest = { 249: type: 'control_request', 250: request_id: randomUUID(), 251: request, 252: } 253: logForDebugging( 254: `[SessionsWebSocket] Sending control request: ${request.subtype}`, 255: ) 256: this.ws.send(jsonStringify(controlRequest)) 257: } 258: isConnected(): boolean { 259: return this.state === 'connected' 260: } 261: close(): void { 262: logForDebugging('[SessionsWebSocket] Closing connection') 263: this.state = 'closed' 264: this.stopPingInterval() 265: if (this.reconnectTimer) { 266: clearTimeout(this.reconnectTimer) 267: this.reconnectTimer = null 268: } 269: if (this.ws) { 270: this.ws.close() 271: this.ws = null 272: } 273: } 274: reconnect(): void { 275: logForDebugging('[SessionsWebSocket] Force reconnecting') 276: this.reconnectAttempts = 0 277: this.sessionNotFoundRetries = 0 278: this.close() 279: this.reconnectTimer = setTimeout(() => { 280: this.reconnectTimer = null 281: void this.connect() 282: }, 500) 283: } 284: }

File: src/schemas/hooks.ts

typescript 1: import { HOOK_EVENTS, type HookEvent } from 'src/entrypoints/agentSdkTypes.js' 2: import { z } from 'zod/v4' 3: import { lazySchema } from '../utils/lazySchema.js' 4: import { SHELL_TYPES } from '../utils/shell/shellProvider.js' 5: const IfConditionSchema = lazySchema(() => 6: z 7: .string() 8: .optional() 9: .describe( 10: 'Permission rule syntax to filter when this hook runs (e.g., "Bash(git *)"). ' + 11: 'Only runs if the tool call matches the pattern. Avoids spawning hooks for non-matching commands.', 12: ), 13: ) 14: function buildHookSchemas() { 15: const BashCommandHookSchema = z.object({ 16: type: z.literal('command').describe('Shell command hook type'), 17: command: z.string().describe('Shell command to execute'), 18: if: IfConditionSchema(), 19: shell: z 20: .enum(SHELL_TYPES) 21: .optional() 22: .describe( 23: "Shell interpreter. 'bash' uses your $SHELL (bash/zsh/sh); 'powershell' uses pwsh. Defaults to bash.", 24: ), 25: timeout: z 26: .number() 27: .positive() 28: .optional() 29: .describe('Timeout in seconds for this specific command'), 30: statusMessage: z 31: .string() 32: .optional() 33: .describe('Custom status message to display in spinner while hook runs'), 34: once: z 35: .boolean() 36: .optional() 37: .describe('If true, hook runs once and is removed after execution'), 38: async: z 39: .boolean() 40: .optional() 41: .describe('If true, hook runs in background without blocking'), 42: asyncRewake: z 43: .boolean() 44: .optional() 45: .describe( 46: 'If true, hook runs in background and wakes the model on exit code 2 (blocking error). Implies async.', 47: ), 48: }) 49: const PromptHookSchema = z.object({ 50: type: z.literal('prompt').describe('LLM prompt hook type'), 51: prompt: z 52: .string() 53: .describe( 54: 'Prompt to evaluate with LLM. Use $ARGUMENTS placeholder for hook input JSON.', 55: ), 56: if: IfConditionSchema(), 57: timeout: z 58: .number() 59: .positive() 60: .optional() 61: .describe('Timeout in seconds for this specific prompt evaluation'), 62: model: z 63: .string() 64: .optional() 65: .describe( 66: 'Model to use for this prompt hook (e.g., "claude-sonnet-4-6"). If not specified, uses the default small fast model.', 67: ), 68: statusMessage: z 69: .string() 70: .optional() 71: .describe('Custom status message to display in spinner while hook runs'), 72: once: z 73: .boolean() 74: .optional() 75: .describe('If true, hook runs once and is removed after execution'), 76: }) 77: const HttpHookSchema = z.object({ 78: type: z.literal('http').describe('HTTP hook type'), 79: url: z.string().url().describe('URL to POST the hook input JSON to'), 80: if: IfConditionSchema(), 81: timeout: z 82: .number() 83: .positive() 84: .optional() 85: .describe('Timeout in seconds for this specific request'), 86: headers: z 87: .record(z.string(), z.string()) 88: .optional() 89: .describe( 90: 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', 91: ), 92: allowedEnvVars: z 93: .array(z.string()) 94: .optional() 95: .describe( 96: 'Explicit list of environment variable names that may be interpolated in header values. Only variables listed here will be resolved; all other $VAR references are left as empty strings. Required for env var interpolation to work.', 97: ), 98: statusMessage: z 99: .string() 100: .optional() 101: .describe('Custom status message to display in spinner while hook runs'), 102: once: z 103: .boolean() 104: .optional() 105: .describe('If true, hook runs once and is removed after execution'), 106: }) 107: const AgentHookSchema = z.object({ 108: type: z.literal('agent').describe('Agentic verifier hook type'), 109: prompt: z 110: .string() 111: .describe( 112: 'Prompt describing what to verify (e.g. "Verify that unit tests ran and passed."). Use $ARGUMENTS placeholder for hook input JSON.', 113: ), 114: if: IfConditionSchema(), 115: timeout: z 116: .number() 117: .positive() 118: .optional() 119: .describe('Timeout in seconds for agent execution (default 60)'), 120: model: z 121: .string() 122: .optional() 123: .describe( 124: 'Model to use for this agent hook (e.g., "claude-sonnet-4-6"). If not specified, uses Haiku.', 125: ), 126: statusMessage: z 127: .string() 128: .optional() 129: .describe('Custom status message to display in spinner while hook runs'), 130: once: z 131: .boolean() 132: .optional() 133: .describe('If true, hook runs once and is removed after execution'), 134: }) 135: return { 136: BashCommandHookSchema, 137: PromptHookSchema, 138: HttpHookSchema, 139: AgentHookSchema, 140: } 141: } 142: export const HookCommandSchema = lazySchema(() => { 143: const { 144: BashCommandHookSchema, 145: PromptHookSchema, 146: AgentHookSchema, 147: HttpHookSchema, 148: } = buildHookSchemas() 149: return z.discriminatedUnion('type', [ 150: BashCommandHookSchema, 151: PromptHookSchema, 152: AgentHookSchema, 153: HttpHookSchema, 154: ]) 155: }) 156: export const HookMatcherSchema = lazySchema(() => 157: z.object({ 158: matcher: z 159: .string() 160: .optional() 161: .describe('String pattern to match (e.g. tool names like "Write")'), 162: hooks: z 163: .array(HookCommandSchema()) 164: .describe('List of hooks to execute when the matcher matches'), 165: }), 166: ) 167: export const HooksSchema = lazySchema(() => 168: z.partialRecord(z.enum(HOOK_EVENTS), z.array(HookMatcherSchema())), 169: ) 170: export type HookCommand = z.infer<ReturnType<typeof HookCommandSchema>> 171: export type BashCommandHook = Extract<HookCommand, { type: 'command' }> 172: export type PromptHook = Extract<HookCommand, { type: 'prompt' }> 173: export type AgentHook = Extract<HookCommand, { type: 'agent' }> 174: export type HttpHook = Extract<HookCommand, { type: 'http' }> 175: export type HookMatcher = z.infer<ReturnType<typeof HookMatcherSchema>> 176: export type HooksSettings = Partial<Record<HookEvent, HookMatcher[]>>

File: src/screens/Doctor.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import figures from 'figures'; 3: import { join } from 'path'; 4: import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; 5: import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js'; 6: import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js'; 7: import { getModelMaxOutputTokens } from 'src/utils/context.js'; 8: import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js'; 9: import type { SettingSource } from 'src/utils/settings/constants.js'; 10: import { getOriginalCwd } from '../bootstrap/state.js'; 11: import type { CommandResultDisplay } from '../commands.js'; 12: import { Pane } from '../components/design-system/Pane.js'; 13: import { PressEnterToContinue } from '../components/PressEnterToContinue.js'; 14: import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js'; 15: import { ValidationErrorsList } from '../components/ValidationErrorsList.js'; 16: import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js'; 17: import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; 18: import { Box, Text } from '../ink.js'; 19: import { useKeybindings } from '../keybindings/useKeybinding.js'; 20: import { useAppState } from '../state/AppState.js'; 21: import { getPluginErrorMessage } from '../types/plugin.js'; 22: import { getGcsDistTags, getNpmDistTags, type NpmDistTags } from '../utils/autoUpdater.js'; 23: import { type ContextWarnings, checkContextWarnings } from '../utils/doctorContextWarnings.js'; 24: import { type DiagnosticInfo, getDoctorDiagnostic } from '../utils/doctorDiagnostic.js'; 25: import { validateBoundedIntEnvVar } from '../utils/envValidation.js'; 26: import { pathExists } from '../utils/file.js'; 27: import { cleanupStaleLocks, getAllLockInfo, isPidBasedLockingEnabled, type LockInfo } from '../utils/nativeInstaller/pidLock.js'; 28: import { getInitialSettings } from '../utils/settings/settings.js'; 29: import { BASH_MAX_OUTPUT_DEFAULT, BASH_MAX_OUTPUT_UPPER_LIMIT } from '../utils/shell/outputLimits.js'; 30: import { TASK_MAX_OUTPUT_DEFAULT, TASK_MAX_OUTPUT_UPPER_LIMIT } from '../utils/task/outputFormatting.js'; 31: import { getXDGStateHome } from '../utils/xdg.js'; 32: type Props = { 33: onDone: (result?: string, options?: { 34: display?: CommandResultDisplay; 35: }) => void; 36: }; 37: type AgentInfo = { 38: activeAgents: Array<{ 39: agentType: string; 40: source: SettingSource | 'built-in' | 'plugin'; 41: }>; 42: userAgentsDir: string; 43: projectAgentsDir: string; 44: userDirExists: boolean; 45: projectDirExists: boolean; 46: failedFiles?: Array<{ 47: path: string; 48: error: string; 49: }>; 50: }; 51: type VersionLockInfo = { 52: enabled: boolean; 53: locks: LockInfo[]; 54: locksDir: string; 55: staleLocksCleaned: number; 56: }; 57: function DistTagsDisplay(t0) { 58: const $ = _c(8); 59: const { 60: promise 61: } = t0; 62: const distTags = use(promise); 63: if (!distTags.latest) { 64: let t1; 65: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 66: t1 = <Text dimColor={true}>└ Failed to fetch versions</Text>; 67: $[0] = t1; 68: } else { 69: t1 = $[0]; 70: } 71: return t1; 72: } 73: let t1; 74: if ($[1] !== distTags.stable) { 75: t1 = distTags.stable && <Text>└ Stable version: {distTags.stable}</Text>; 76: $[1] = distTags.stable; 77: $[2] = t1; 78: } else { 79: t1 = $[2]; 80: } 81: let t2; 82: if ($[3] !== distTags.latest) { 83: t2 = <Text>└ Latest version: {distTags.latest}</Text>; 84: $[3] = distTags.latest; 85: $[4] = t2; 86: } else { 87: t2 = $[4]; 88: } 89: let t3; 90: if ($[5] !== t1 || $[6] !== t2) { 91: t3 = <>{t1}{t2}</>; 92: $[5] = t1; 93: $[6] = t2; 94: $[7] = t3; 95: } else { 96: t3 = $[7]; 97: } 98: return t3; 99: } 100: export function Doctor(t0) { 101: const $ = _c(84); 102: const { 103: onDone 104: } = t0; 105: const agentDefinitions = useAppState(_temp); 106: const mcpTools = useAppState(_temp2); 107: const toolPermissionContext = useAppState(_temp3); 108: const pluginsErrors = useAppState(_temp4); 109: useExitOnCtrlCDWithKeybindings(); 110: let t1; 111: if ($[0] !== mcpTools) { 112: t1 = mcpTools || []; 113: $[0] = mcpTools; 114: $[1] = t1; 115: } else { 116: t1 = $[1]; 117: } 118: const tools = t1; 119: const [diagnostic, setDiagnostic] = useState(null); 120: const [agentInfo, setAgentInfo] = useState(null); 121: const [contextWarnings, setContextWarnings] = useState(null); 122: const [versionLockInfo, setVersionLockInfo] = useState(null); 123: const validationErrors = useSettingsErrors(); 124: let t2; 125: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 126: t2 = getDoctorDiagnostic().then(_temp6); 127: $[2] = t2; 128: } else { 129: t2 = $[2]; 130: } 131: const distTagsPromise = t2; 132: const autoUpdatesChannel = getInitialSettings()?.autoUpdatesChannel ?? "latest"; 133: let t3; 134: if ($[3] !== validationErrors) { 135: t3 = validationErrors.filter(_temp7); 136: $[3] = validationErrors; 137: $[4] = t3; 138: } else { 139: t3 = $[4]; 140: } 141: const errorsExcludingMcp = t3; 142: let t4; 143: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 144: const envVars = [{ 145: name: "BASH_MAX_OUTPUT_LENGTH", 146: default: BASH_MAX_OUTPUT_DEFAULT, 147: upperLimit: BASH_MAX_OUTPUT_UPPER_LIMIT 148: }, { 149: name: "TASK_MAX_OUTPUT_LENGTH", 150: default: TASK_MAX_OUTPUT_DEFAULT, 151: upperLimit: TASK_MAX_OUTPUT_UPPER_LIMIT 152: }, { 153: name: "CLAUDE_CODE_MAX_OUTPUT_TOKENS", 154: ...getModelMaxOutputTokens("claude-opus-4-6") 155: }]; 156: t4 = envVars.map(_temp8).filter(_temp9); 157: $[5] = t4; 158: } else { 159: t4 = $[5]; 160: } 161: const envValidationErrors = t4; 162: let t5; 163: let t6; 164: if ($[6] !== agentDefinitions || $[7] !== toolPermissionContext || $[8] !== tools) { 165: t5 = () => { 166: getDoctorDiagnostic().then(setDiagnostic); 167: (async () => { 168: const userAgentsDir = join(getClaudeConfigHomeDir(), "agents"); 169: const projectAgentsDir = join(getOriginalCwd(), ".claude", "agents"); 170: const { 171: activeAgents, 172: allAgents, 173: failedFiles 174: } = agentDefinitions; 175: const [userDirExists, projectDirExists] = await Promise.all([pathExists(userAgentsDir), pathExists(projectAgentsDir)]); 176: const agentInfoData = { 177: activeAgents: activeAgents.map(_temp0), 178: userAgentsDir, 179: projectAgentsDir, 180: userDirExists, 181: projectDirExists, 182: failedFiles 183: }; 184: setAgentInfo(agentInfoData); 185: const warnings = await checkContextWarnings(tools, { 186: activeAgents, 187: allAgents, 188: failedFiles 189: }, async () => toolPermissionContext); 190: setContextWarnings(warnings); 191: if (isPidBasedLockingEnabled()) { 192: const locksDir = join(getXDGStateHome(), "claude", "locks"); 193: const staleLocksCleaned = cleanupStaleLocks(locksDir); 194: const locks = getAllLockInfo(locksDir); 195: setVersionLockInfo({ 196: enabled: true, 197: locks, 198: locksDir, 199: staleLocksCleaned 200: }); 201: } else { 202: setVersionLockInfo({ 203: enabled: false, 204: locks: [], 205: locksDir: "", 206: staleLocksCleaned: 0 207: }); 208: } 209: })(); 210: }; 211: t6 = [toolPermissionContext, tools, agentDefinitions]; 212: $[6] = agentDefinitions; 213: $[7] = toolPermissionContext; 214: $[8] = tools; 215: $[9] = t5; 216: $[10] = t6; 217: } else { 218: t5 = $[9]; 219: t6 = $[10]; 220: } 221: useEffect(t5, t6); 222: let t7; 223: if ($[11] !== onDone) { 224: t7 = () => { 225: onDone("Claude Code diagnostics dismissed", { 226: display: "system" 227: }); 228: }; 229: $[11] = onDone; 230: $[12] = t7; 231: } else { 232: t7 = $[12]; 233: } 234: const handleDismiss = t7; 235: let t8; 236: if ($[13] !== handleDismiss) { 237: t8 = { 238: "confirm:yes": handleDismiss, 239: "confirm:no": handleDismiss 240: }; 241: $[13] = handleDismiss; 242: $[14] = t8; 243: } else { 244: t8 = $[14]; 245: } 246: let t9; 247: if ($[15] === Symbol.for("react.memo_cache_sentinel")) { 248: t9 = { 249: context: "Confirmation" 250: }; 251: $[15] = t9; 252: } else { 253: t9 = $[15]; 254: } 255: useKeybindings(t8, t9); 256: if (!diagnostic) { 257: let t10; 258: if ($[16] === Symbol.for("react.memo_cache_sentinel")) { 259: t10 = <Pane><Text dimColor={true}>Checking installation status…</Text></Pane>; 260: $[16] = t10; 261: } else { 262: t10 = $[16]; 263: } 264: return t10; 265: } 266: let t10; 267: if ($[17] === Symbol.for("react.memo_cache_sentinel")) { 268: t10 = <Text bold={true}>Diagnostics</Text>; 269: $[17] = t10; 270: } else { 271: t10 = $[17]; 272: } 273: let t11; 274: if ($[18] !== diagnostic.installationType || $[19] !== diagnostic.version) { 275: t11 = <Text>└ Currently running: {diagnostic.installationType} ({diagnostic.version})</Text>; 276: $[18] = diagnostic.installationType; 277: $[19] = diagnostic.version; 278: $[20] = t11; 279: } else { 280: t11 = $[20]; 281: } 282: let t12; 283: if ($[21] !== diagnostic.packageManager) { 284: t12 = diagnostic.packageManager && <Text>└ Package manager: {diagnostic.packageManager}</Text>; 285: $[21] = diagnostic.packageManager; 286: $[22] = t12; 287: } else { 288: t12 = $[22]; 289: } 290: let t13; 291: if ($[23] !== diagnostic.installationPath) { 292: t13 = <Text>└ Path: {diagnostic.installationPath}</Text>; 293: $[23] = diagnostic.installationPath; 294: $[24] = t13; 295: } else { 296: t13 = $[24]; 297: } 298: let t14; 299: if ($[25] !== diagnostic.invokedBinary) { 300: t14 = <Text>└ Invoked: {diagnostic.invokedBinary}</Text>; 301: $[25] = diagnostic.invokedBinary; 302: $[26] = t14; 303: } else { 304: t14 = $[26]; 305: } 306: let t15; 307: if ($[27] !== diagnostic.configInstallMethod) { 308: t15 = <Text>└ Config install method: {diagnostic.configInstallMethod}</Text>; 309: $[27] = diagnostic.configInstallMethod; 310: $[28] = t15; 311: } else { 312: t15 = $[28]; 313: } 314: const t16 = diagnostic.ripgrepStatus.working ? "OK" : "Not working"; 315: const t17 = diagnostic.ripgrepStatus.mode === "embedded" ? "bundled" : diagnostic.ripgrepStatus.mode === "builtin" ? "vendor" : diagnostic.ripgrepStatus.systemPath || "system"; 316: let t18; 317: if ($[29] !== t16 || $[30] !== t17) { 318: t18 = <Text>└ Search: {t16} ({t17})</Text>; 319: $[29] = t16; 320: $[30] = t17; 321: $[31] = t18; 322: } else { 323: t18 = $[31]; 324: } 325: let t19; 326: if ($[32] !== diagnostic.recommendation) { 327: t19 = diagnostic.recommendation && <><Text /><Text color="warning">Recommendation: {diagnostic.recommendation.split("\n")[0]}</Text><Text dimColor={true}>{diagnostic.recommendation.split("\n")[1]}</Text></>; 328: $[32] = diagnostic.recommendation; 329: $[33] = t19; 330: } else { 331: t19 = $[33]; 332: } 333: let t20; 334: if ($[34] !== diagnostic.multipleInstallations) { 335: t20 = diagnostic.multipleInstallations.length > 1 && <><Text /><Text color="warning">Warning: Multiple installations found</Text>{diagnostic.multipleInstallations.map(_temp1)}</>; 336: $[34] = diagnostic.multipleInstallations; 337: $[35] = t20; 338: } else { 339: t20 = $[35]; 340: } 341: let t21; 342: if ($[36] !== diagnostic.warnings) { 343: t21 = diagnostic.warnings.length > 0 && <><Text />{diagnostic.warnings.map(_temp10)}</>; 344: $[36] = diagnostic.warnings; 345: $[37] = t21; 346: } else { 347: t21 = $[37]; 348: } 349: let t22; 350: if ($[38] !== errorsExcludingMcp) { 351: t22 = errorsExcludingMcp.length > 0 && <Box flexDirection="column" marginTop={1} marginBottom={1}><Text bold={true}>Invalid Settings</Text><ValidationErrorsList errors={errorsExcludingMcp} /></Box>; 352: $[38] = errorsExcludingMcp; 353: $[39] = t22; 354: } else { 355: t22 = $[39]; 356: } 357: let t23; 358: if ($[40] !== t11 || $[41] !== t12 || $[42] !== t13 || $[43] !== t14 || $[44] !== t15 || $[45] !== t18 || $[46] !== t19 || $[47] !== t20 || $[48] !== t21 || $[49] !== t22) { 359: t23 = <Box flexDirection="column">{t10}{t11}{t12}{t13}{t14}{t15}{t18}{t19}{t20}{t21}{t22}</Box>; 360: $[40] = t11; 361: $[41] = t12; 362: $[42] = t13; 363: $[43] = t14; 364: $[44] = t15; 365: $[45] = t18; 366: $[46] = t19; 367: $[47] = t20; 368: $[48] = t21; 369: $[49] = t22; 370: $[50] = t23; 371: } else { 372: t23 = $[50]; 373: } 374: let t24; 375: if ($[51] === Symbol.for("react.memo_cache_sentinel")) { 376: t24 = <Text bold={true}>Updates</Text>; 377: $[51] = t24; 378: } else { 379: t24 = $[51]; 380: } 381: const t25 = diagnostic.packageManager ? "Managed by package manager" : diagnostic.autoUpdates; 382: let t26; 383: if ($[52] !== t25) { 384: t26 = <Text>└ Auto-updates:{" "}{t25}</Text>; 385: $[52] = t25; 386: $[53] = t26; 387: } else { 388: t26 = $[53]; 389: } 390: let t27; 391: if ($[54] !== diagnostic.hasUpdatePermissions) { 392: t27 = diagnostic.hasUpdatePermissions !== null && <Text>└ Update permissions:{" "}{diagnostic.hasUpdatePermissions ? "Yes" : "No (requires sudo)"}</Text>; 393: $[54] = diagnostic.hasUpdatePermissions; 394: $[55] = t27; 395: } else { 396: t27 = $[55]; 397: } 398: let t28; 399: if ($[56] === Symbol.for("react.memo_cache_sentinel")) { 400: t28 = <Text>└ Auto-update channel: {autoUpdatesChannel}</Text>; 401: $[56] = t28; 402: } else { 403: t28 = $[56]; 404: } 405: let t29; 406: if ($[57] === Symbol.for("react.memo_cache_sentinel")) { 407: t29 = <Suspense fallback={null}><DistTagsDisplay promise={distTagsPromise} /></Suspense>; 408: $[57] = t29; 409: } else { 410: t29 = $[57]; 411: } 412: let t30; 413: if ($[58] !== t26 || $[59] !== t27) { 414: t30 = <Box flexDirection="column">{t24}{t26}{t27}{t28}{t29}</Box>; 415: $[58] = t26; 416: $[59] = t27; 417: $[60] = t30; 418: } else { 419: t30 = $[60]; 420: } 421: let t31; 422: let t32; 423: let t33; 424: let t34; 425: if ($[61] === Symbol.for("react.memo_cache_sentinel")) { 426: t31 = <SandboxDoctorSection />; 427: t32 = <McpParsingWarnings />; 428: t33 = <KeybindingWarnings />; 429: t34 = envValidationErrors.length > 0 && <Box flexDirection="column"><Text bold={true}>Environment Variables</Text>{envValidationErrors.map(_temp11)}</Box>; 430: $[61] = t31; 431: $[62] = t32; 432: $[63] = t33; 433: $[64] = t34; 434: } else { 435: t31 = $[61]; 436: t32 = $[62]; 437: t33 = $[63]; 438: t34 = $[64]; 439: } 440: let t35; 441: if ($[65] !== versionLockInfo) { 442: t35 = versionLockInfo?.enabled && <Box flexDirection="column"><Text bold={true}>Version Locks</Text>{versionLockInfo.staleLocksCleaned > 0 && <Text dimColor={true}>└ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s)</Text>}{versionLockInfo.locks.length === 0 ? <Text dimColor={true}>└ No active version locks</Text> : versionLockInfo.locks.map(_temp12)}</Box>; 443: $[65] = versionLockInfo; 444: $[66] = t35; 445: } else { 446: t35 = $[66]; 447: } 448: let t36; 449: if ($[67] !== agentInfo) { 450: t36 = agentInfo?.failedFiles && agentInfo.failedFiles.length > 0 && <Box flexDirection="column"><Text bold={true} color="error">Agent Parse Errors</Text><Text color="error">└ Failed to parse {agentInfo.failedFiles.length} agent file(s):</Text>{agentInfo.failedFiles.map(_temp13)}</Box>; 451: $[67] = agentInfo; 452: $[68] = t36; 453: } else { 454: t36 = $[68]; 455: } 456: let t37; 457: if ($[69] !== pluginsErrors) { 458: t37 = pluginsErrors.length > 0 && <Box flexDirection="column"><Text bold={true} color="error">Plugin Errors</Text><Text color="error">└ {pluginsErrors.length} plugin error(s) detected:</Text>{pluginsErrors.map(_temp14)}</Box>; 459: $[69] = pluginsErrors; 460: $[70] = t37; 461: } else { 462: t37 = $[70]; 463: } 464: let t38; 465: if ($[71] !== contextWarnings) { 466: t38 = contextWarnings?.unreachableRulesWarning && <Box flexDirection="column"><Text bold={true} color="warning">Unreachable Permission Rules</Text><Text>└{" "}<Text color="warning">{figures.warning}{" "}{contextWarnings.unreachableRulesWarning.message}</Text></Text>{contextWarnings.unreachableRulesWarning.details.map(_temp15)}</Box>; 467: $[71] = contextWarnings; 468: $[72] = t38; 469: } else { 470: t38 = $[72]; 471: } 472: let t39; 473: if ($[73] !== contextWarnings) { 474: t39 = contextWarnings && (contextWarnings.claudeMdWarning || contextWarnings.agentWarning || contextWarnings.mcpWarning) && <Box flexDirection="column"><Text bold={true}>Context Usage Warnings</Text>{contextWarnings.claudeMdWarning && <><Text>└{" "}<Text color="warning">{figures.warning} {contextWarnings.claudeMdWarning.message}</Text></Text><Text>{" "}└ Files:</Text>{contextWarnings.claudeMdWarning.details.map(_temp16)}</>}{contextWarnings.agentWarning && <><Text>└{" "}<Text color="warning">{figures.warning} {contextWarnings.agentWarning.message}</Text></Text><Text>{" "}└ Top contributors:</Text>{contextWarnings.agentWarning.details.map(_temp17)}</>}{contextWarnings.mcpWarning && <><Text>└{" "}<Text color="warning">{figures.warning} {contextWarnings.mcpWarning.message}</Text></Text><Text>{" "}└ MCP servers:</Text>{contextWarnings.mcpWarning.details.map(_temp18)}</>}</Box>; 475: $[73] = contextWarnings; 476: $[74] = t39; 477: } else { 478: t39 = $[74]; 479: } 480: let t40; 481: if ($[75] === Symbol.for("react.memo_cache_sentinel")) { 482: t40 = <Box><PressEnterToContinue /></Box>; 483: $[75] = t40; 484: } else { 485: t40 = $[75]; 486: } 487: let t41; 488: if ($[76] !== t23 || $[77] !== t30 || $[78] !== t35 || $[79] !== t36 || $[80] !== t37 || $[81] !== t38 || $[82] !== t39) { 489: t41 = <Pane>{t23}{t30}{t31}{t32}{t33}{t34}{t35}{t36}{t37}{t38}{t39}{t40}</Pane>; 490: $[76] = t23; 491: $[77] = t30; 492: $[78] = t35; 493: $[79] = t36; 494: $[80] = t37; 495: $[81] = t38; 496: $[82] = t39; 497: $[83] = t41; 498: } else { 499: t41 = $[83]; 500: } 501: return t41; 502: } 503: function _temp18(detail_2, i_8) { 504: return <Text key={i_8} dimColor={true}>{" "}└ {detail_2}</Text>; 505: } 506: function _temp17(detail_1, i_7) { 507: return <Text key={i_7} dimColor={true}>{" "}└ {detail_1}</Text>; 508: } 509: function _temp16(detail_0, i_6) { 510: return <Text key={i_6} dimColor={true}>{" "}└ {detail_0}</Text>; 511: } 512: function _temp15(detail, i_5) { 513: return <Text key={i_5} dimColor={true}>{" "}└ {detail}</Text>; 514: } 515: function _temp14(error_0, i_4) { 516: return <Text key={i_4} dimColor={true}>{" "}└ {error_0.source || "unknown"}{"plugin" in error_0 && error_0.plugin ? ` [${error_0.plugin}]` : ""}:{" "}{getPluginErrorMessage(error_0)}</Text>; 517: } 518: function _temp13(file, i_3) { 519: return <Text key={i_3} dimColor={true}>{" "}└ {file.path}: {file.error}</Text>; 520: } 521: function _temp12(lock, i_2) { 522: return <Text key={i_2}>└ {lock.version}: PID {lock.pid}{" "}{lock.isProcessRunning ? <Text>(running)</Text> : <Text color="warning">(stale)</Text>}</Text>; 523: } 524: function _temp11(validation, i_1) { 525: return <Text key={i_1}>└ {validation.name}:{" "}<Text color={validation.status === "capped" ? "warning" : "error"}>{validation.message}</Text></Text>; 526: } 527: function _temp10(warning, i_0) { 528: return <Box key={i_0} flexDirection="column"><Text color="warning">Warning: {warning.issue}</Text><Text>Fix: {warning.fix}</Text></Box>; 529: } 530: function _temp1(install, i) { 531: return <Text key={i}>└ {install.type} at {install.path}</Text>; 532: } 533: function _temp0(a) { 534: return { 535: agentType: a.agentType, 536: source: a.source 537: }; 538: } 539: function _temp9(v_0) { 540: return v_0.status !== "valid"; 541: } 542: function _temp8(v) { 543: const value = process.env[v.name]; 544: const result = validateBoundedIntEnvVar(v.name, value, v.default, v.upperLimit); 545: return { 546: name: v.name, 547: ...result 548: }; 549: } 550: function _temp7(error) { 551: return error.mcpErrorMetadata === undefined; 552: } 553: function _temp6(diag) { 554: const fetchDistTags = diag.installationType === "native" ? getGcsDistTags : getNpmDistTags; 555: return fetchDistTags().catch(_temp5); 556: } 557: function _temp5() { 558: return { 559: latest: null, 560: stable: null 561: }; 562: } 563: function _temp4(s_2) { 564: return s_2.plugins.errors; 565: } 566: function _temp3(s_1) { 567: return s_1.toolPermissionContext; 568: } 569: function _temp2(s_0) { 570: return s_0.mcp.tools; 571: } 572: function _temp(s) { 573: return s.agentDefinitions; 574: }

File: src/screens/REPL.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import { spawnSync } from 'child_process'; 4: import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens } from '../bootstrap/state.js'; 5: import { parseTokenBudget } from '../utils/tokenBudget.js'; 6: import { count } from '../utils/array.js'; 7: import { dirname, join } from 'path'; 8: import { tmpdir } from 'os'; 9: import figures from 'figures'; 10: import { useInput } from '../ink.js'; 11: import { useSearchInput } from '../hooks/useSearchInput.js'; 12: import { useTerminalSize } from '../hooks/useTerminalSize.js'; 13: import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'; 14: import type { JumpHandle } from '../components/VirtualMessageList.js'; 15: import { renderMessagesToPlainText } from '../utils/exportRenderer.js'; 16: import { openFileInExternalEditor } from '../utils/editor.js'; 17: import { writeFile } from 'fs/promises'; 18: import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '../ink.js'; 19: import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'; 20: import { CostThresholdDialog } from '../components/CostThresholdDialog.js'; 21: import { IdleReturnDialog } from '../components/IdleReturnDialog.js'; 22: import * as React from 'react'; 23: import { useEffect, useMemo, useRef, useState, useCallback, useDeferredValue, useLayoutEffect, type RefObject } from 'react'; 24: import { useNotifications } from '../context/notifications.js'; 25: import { sendNotification } from '../services/notifier.js'; 26: import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js'; 27: import { useTerminalNotification } from '../ink/useTerminalNotification.js'; 28: import { hasCursorUpViewportYankBug } from '../ink/terminal.js'; 29: import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE } from '../utils/fileStateCache.js'; 30: import { updateLastInteractionTime, getLastInteractionTime, getOriginalCwd, getProjectRoot, getSessionId, switchSession, setCostStateForRestore, getTurnHookDurationMs, getTurnHookCount, resetTurnHookDuration, getTurnToolDurationMs, getTurnToolCount, resetTurnToolDuration, getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration } from '../bootstrap/state.js'; 31: import { asSessionId, asAgentId } from '../types/ids.js'; 32: import { logForDebugging } from '../utils/debug.js'; 33: import { QueryGuard } from '../utils/QueryGuard.js'; 34: import { isEnvTruthy } from '../utils/envUtils.js'; 35: import { formatTokens, truncateToWidth } from '../utils/format.js'; 36: import { consumeEarlyInput } from '../utils/earlyInput.js'; 37: import { setMemberActive } from '../utils/swarm/teamHelpers.js'; 38: import { isSwarmWorker, generateSandboxRequestId, sendSandboxPermissionRequestViaMailbox, sendSandboxPermissionResponseViaMailbox } from '../utils/swarm/permissionSync.js'; 39: import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js'; 40: import { getTeamName, getAgentName } from '../utils/teammate.js'; 41: import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js'; 42: import { injectUserMessageToTeammate, getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; 43: import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; 44: import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext } from '../utils/swarm/leaderPermissionBridge.js'; 45: import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'; 46: import { useLogMessages } from '../hooks/useLogMessages.js'; 47: import { useReplBridge } from '../hooks/useReplBridge.js'; 48: import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled } from '../commands.js'; 49: import type { PromptInputMode, QueuedCommand, VimMode } from '../types/textInputTypes.js'; 50: import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic } from '../components/MessageSelector.js'; 51: import { useIdeLogging } from '../hooks/useIdeLogging.js'; 52: import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js'; 53: import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js'; 54: import { PromptDialog } from '../components/hooks/PromptDialog.js'; 55: import type { PromptRequest, PromptResponse } from '../types/hooks.js'; 56: import PromptInput from '../components/PromptInput/PromptInput.js'; 57: import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js'; 58: import { useRemoteSession } from '../hooks/useRemoteSession.js'; 59: import { useDirectConnect } from '../hooks/useDirectConnect.js'; 60: import type { DirectConnectConfig } from '../server/directConnectManager.js'; 61: import { useSSHSession } from '../hooks/useSSHSession.js'; 62: import { useAssistantHistory } from '../hooks/useAssistantHistory.js'; 63: import type { SSHSession } from '../ssh/createSSHSession.js'; 64: import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'; 65: import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'; 66: import { useMoreRight } from '../moreright/useMoreRight.js'; 67: import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js'; 68: import { getSystemPrompt } from '../constants/prompts.js'; 69: import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js'; 70: import { getSystemContext, getUserContext } from '../context.js'; 71: import { getMemoryFiles } from '../utils/claudemd.js'; 72: import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js'; 73: import { getTotalCost, saveCurrentSessionCosts, resetCostState, getStoredSessionCosts } from '../cost-tracker.js'; 74: import { useCostSummary } from '../costHook.js'; 75: import { useFpsMetrics } from '../context/fpsMetrics.js'; 76: import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js'; 77: import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js'; 78: import { addToHistory, removeLastFromHistory, expandPastedTextRefs, parseReferences } from '../history.js'; 79: import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js'; 80: import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js'; 81: import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js'; 82: import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js'; 83: import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js'; 84: import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; 85: import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; 86: import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; 87: import { CancelRequestHandler } from '../hooks/useCancelRequest.js'; 88: import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js'; 89: import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js'; 90: import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js'; 91: import { errorMessage } from '../utils/errors.js'; 92: import { isHumanTurn } from '../utils/messagePredicates.js'; 93: import { logError } from '../utils/log.js'; 94: const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration : () => ({ 95: stripTrailing: () => 0, 96: handleKeyEvent: () => {}, 97: resetAnchor: () => {} 98: }); 99: const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature('VOICE_MODE') ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler : () => null; 100: const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = "external" === 'ant' ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection : () => ({ 101: state: 'closed', 102: handleTranscriptSelect: () => {} 103: }); 104: const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = "external" === 'ant' ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification : () => {}; 105: const getCoordinatorUserContext: (mcpClients: ReadonlyArray<{ 106: name: string; 107: }>, scratchpadDir?: string) => { 108: [k: string]: string; 109: } = feature('COORDINATOR_MODE') ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext : () => ({}); 110: import useCanUseTool from '../hooks/useCanUseTool.js'; 111: import type { ToolPermissionContext, Tool } from '../Tool.js'; 112: import { applyPermissionUpdate, applyPermissionUpdates, persistPermissionUpdate } from '../utils/permissions/PermissionUpdate.js'; 113: import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; 114: import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js'; 115: import { getScratchpadDir, isScratchpadEnabled } from '../utils/permissions/filesystem.js'; 116: import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'; 117: import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js'; 118: import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js'; 119: import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; 120: import { getGlobalConfig, saveGlobalConfig, getGlobalConfigWriteCount } from '../utils/config.js'; 121: import { hasConsoleBillingAccess } from '../utils/billing.js'; 122: import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js'; 123: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; 124: import { textForResubmit, handleMessageFromStream, type StreamingToolUse, type StreamingThinking, isCompactBoundaryMessage, getMessagesAfterCompactBoundary, getContentText, createUserMessage, createAssistantMessage, createTurnDurationMessage, createAgentsKilledMessage, createApiMetricsMessage, createSystemMessage, createCommandInputMessage, formatCommandInputTags } from '../utils/messages.js'; 125: import { generateSessionTitle } from '../utils/sessionTitle.js'; 126: import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG } from '../constants/xml.js'; 127: import { escapeXml } from '../utils/xml.js'; 128: import type { ThinkingConfig } from '../utils/thinking.js'; 129: import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; 130: import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js'; 131: import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; 132: import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; 133: import { queryCheckpoint, logQueryProfileReport } from '../utils/queryProfiler.js'; 134: import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js'; 135: import { query } from '../query.js'; 136: import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'; 137: import { getQuerySourceForREPL } from '../utils/promptCategory.js'; 138: import { useMergedTools } from '../hooks/useMergedTools.js'; 139: import { mergeAndFilterTools } from '../utils/toolPool.js'; 140: import { useMergedCommands } from '../hooks/useMergedCommands.js'; 141: import { useSkillsChange } from '../hooks/useSkillsChange.js'; 142: import { useManagePlugins } from '../hooks/useManagePlugins.js'; 143: import { Messages } from '../components/Messages.js'; 144: import { TaskListV2 } from '../components/TaskListV2.js'; 145: import { TeammateViewHeader } from '../components/TeammateViewHeader.js'; 146: import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js'; 147: import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'; 148: import type { MCPServerConnection } from '../services/mcp/types.js'; 149: import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; 150: import { randomUUID, type UUID } from 'crypto'; 151: import { processSessionStartHooks } from '../utils/sessionStart.js'; 152: import { executeSessionEndHooks, getSessionEndHookTimeoutMs } from '../utils/hooks.js'; 153: import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js'; 154: import { getTools, assembleToolPool } from '../tools.js'; 155: import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; 156: import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js'; 157: import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js'; 158: import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; 159: import { useAppState, useSetAppState, useAppStateStore } from '../state/AppState.js'; 160: import type { ContentBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; 161: import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js'; 162: import type { PastedContent } from '../utils/config.js'; 163: import { copyPlanForFork, copyPlanForResume, getPlanSlug, setPlanSlug } from '../utils/plans.js'; 164: import { clearSessionMetadata, resetSessionFilePointer, adoptResumedSessionFile, removeTranscriptMessage, restoreSessionMetadata, getCurrentSessionTitle, isEphemeralToolProgress, isLoggableMessage, saveWorktreeState, getAgentTranscript } from '../utils/sessionStorage.js'; 165: import { deserializeMessages } from '../utils/conversationRecovery.js'; 166: import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js'; 167: import { resetMicrocompactState } from '../services/compact/microCompact.js'; 168: import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js'; 169: import { provisionContentReplacementState, reconstructContentReplacementState, type ContentReplacementRecord } from '../utils/toolResultStorage.js'; 170: import { partialCompactConversation } from '../services/compact/compact.js'; 171: import type { LogOption } from '../types/logs.js'; 172: import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; 173: import { fileHistoryMakeSnapshot, type FileHistoryState, fileHistoryRewind, type FileHistorySnapshot, copyFileHistoryForResume, fileHistoryEnabled, fileHistoryHasAnyChanges } from '../utils/fileHistory.js'; 174: import { type AttributionState, incrementPromptCount } from '../utils/commitAttribution.js'; 175: import { recordAttributionSnapshot } from '../utils/sessionStorage.js'; 176: import { computeStandaloneAgentContext, restoreAgentFromSession, restoreSessionStateFromLog, restoreWorktreeForResume, exitRestoredWorktree } from '../utils/sessionRestore.js'; 177: import { isBgSession, updateSessionName, updateSessionActivity } from '../utils/concurrentSessions.js'; 178: import { isInProcessTeammateTask, type InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'; 179: import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; 180: import { useInboxPoller } from '../hooks/useInboxPoller.js'; 181: const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; 182: const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; 183: const PROACTIVE_FALSE = () => false; 184: const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; 185: const useProactive = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; 186: const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null; 187: import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; 188: import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; 189: import type { SandboxAskCallback, NetworkHostPattern } from '../utils/sandbox/sandbox-adapter.js'; 190: import { type IDEExtensionInstallationStatus, closeOpenDiffs, getConnectedIdeClient, type IdeType } from '../utils/ide.js'; 191: import { useIDEIntegration } from '../hooks/useIDEIntegration.js'; 192: import exit from '../commands/exit/index.js'; 193: import { ExitFlow } from '../components/ExitFlow.js'; 194: import { getCurrentWorktreeSession } from '../utils/worktree.js'; 195: import { popAllEditable, enqueue, type SetAppState, getCommandQueue, getCommandQueueLength, removeByFilter } from '../utils/messageQueueManager.js'; 196: import { useCommandQueue } from '../hooks/useCommandQueue.js'; 197: import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js'; 198: import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js'; 199: import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js'; 200: import { diagnosticTracker } from '../services/diagnosticTracking.js'; 201: import { handleSpeculationAccept, type ActiveSpeculationState } from '../services/PromptSuggestion/speculation.js'; 202: import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js'; 203: import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCallout.js'; 204: import type { EffortValue } from '../utils/effort.js'; 205: import { RemoteCallout } from '../components/RemoteCallout.js'; 206: const AntModelSwitchCallout = "external" === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null; 207: const shouldShowAntModelSwitch = "external" === 'ant' ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout : (): boolean => false; 208: const UndercoverAutoCallout = "external" === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null; 209: import { activityManager } from '../utils/activityManager.js'; 210: import { createAbortController } from '../utils/abortController.js'; 211: import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js'; 212: import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js'; 213: import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js'; 214: import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js'; 215: import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js'; 216: import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js'; 217: import { useAwaySummary } from 'src/hooks/useAwaySummary.js'; 218: import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js'; 219: import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js'; 220: import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js'; 221: import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; 222: import type { Theme } from 'src/utils/theme.js'; 223: import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded } from 'src/utils/permissions/bypassPermissionsKillswitch.js'; 224: import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; 225: import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'; 226: import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js'; 227: import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js'; 228: import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'; 229: import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'; 230: import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'; 231: import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js'; 232: import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'; 233: import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'; 234: import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'; 235: import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js'; 236: import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js'; 237: import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js'; 238: import { DesktopUpsellStartup, shouldShowDesktopUpsellStartup } from 'src/components/DesktopUpsell/DesktopUpsellStartup.js'; 239: import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js'; 240: import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js'; 241: import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js'; 242: import { UserTextMessage } from 'src/components/messages/UserTextMessage.js'; 243: import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js'; 244: import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js'; 245: import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js'; 246: import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js'; 247: import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js'; 248: import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js'; 249: import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js'; 250: import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js'; 251: import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js'; 252: import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason } from '../utils/autoRunIssue.js'; 253: import type { HookProgress } from '../types/hooks.js'; 254: import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js'; 255: const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js') : null; 256: import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'; 257: import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'; 258: import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js'; 259: import { DevBar } from '../components/DevBar.js'; 260: import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; 261: import { REMOTE_SAFE_COMMANDS } from '../commands.js'; 262: import type { RemoteMessageContent } from '../utils/teleport/api.js'; 263: import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js'; 264: import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js'; 265: import { AlternateScreen } from '../ink/components/AlternateScreen.js'; 266: import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'; 267: import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type MessageActionsState, type MessageActionsNav, type MessageActionCaps } from '../components/messageActions.js'; 268: import { setClipboard } from '../ink/termio/osc.js'; 269: import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; 270: import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; 271: const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; 272: const HISTORY_STUB = { 273: maybeLoadOlder: (_: ScrollBoxHandle) => {} 274: }; 275: const RECENT_SCROLL_REPIN_WINDOW_MS = 3000; 276: function median(values: number[]): number { 277: const sorted = [...values].sort((a, b) => a - b); 278: const mid = Math.floor(sorted.length / 2); 279: return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) : sorted[mid]!; 280: } 281: function TranscriptModeFooter(t0) { 282: const $ = _c(9); 283: const { 284: showAllInTranscript, 285: virtualScroll, 286: searchBadge, 287: suppressShowAll: t1, 288: status 289: } = t0; 290: const suppressShowAll = t1 === undefined ? false : t1; 291: const toggleShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); 292: const showAllShortcut = useShortcutDisplay("transcript:toggleShowAll", "Transcript", "ctrl+e"); 293: const t2 = searchBadge ? " \xB7 n/N to navigate" : virtualScroll ? ` · ${figures.arrowUp}${figures.arrowDown} scroll · home/end top/bottom` : suppressShowAll ? "" : ` · ${showAllShortcut} to ${showAllInTranscript ? "collapse" : "show all"}`; 294: let t3; 295: if ($[0] !== t2 || $[1] !== toggleShortcut) { 296: t3 = <Text dimColor={true}>Showing detailed transcript · {toggleShortcut} to toggle{t2}</Text>; 297: $[0] = t2; 298: $[1] = toggleShortcut; 299: $[2] = t3; 300: } else { 301: t3 = $[2]; 302: } 303: let t4; 304: if ($[3] !== searchBadge || $[4] !== status) { 305: t4 = status ? <><Box flexGrow={1} /><Text>{status} </Text></> : searchBadge ? <><Box flexGrow={1} /><Text dimColor={true}>{searchBadge.current}/{searchBadge.count}{" "}</Text></> : null; 306: $[3] = searchBadge; 307: $[4] = status; 308: $[5] = t4; 309: } else { 310: t4 = $[5]; 311: } 312: let t5; 313: if ($[6] !== t3 || $[7] !== t4) { 314: t5 = <Box noSelect={true} alignItems="center" alignSelf="center" borderTopDimColor={true} borderBottom={false} borderLeft={false} borderRight={false} borderStyle="single" marginTop={1} paddingLeft={2} width="100%">{t3}{t4}</Box>; 315: $[6] = t3; 316: $[7] = t4; 317: $[8] = t5; 318: } else { 319: t5 = $[8]; 320: } 321: return t5; 322: } 323: /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter 324: * so swapping them in the bottom slot doesn't shift ScrollBox height. 325: * useSearchInput handles readline editing; we report query changes and 326: * render the counter. Incremental — re-search + highlight per keystroke. */ 327: function TranscriptSearchBar({ 328: jumpRef, 329: count, 330: current, 331: onClose, 332: onCancel, 333: setHighlight, 334: initialQuery 335: }: { 336: jumpRef: RefObject<JumpHandle | null>; 337: count: number; 338: current: number; 339: /** Enter — commit. Query persists for n/N. */ 340: onClose: (lastQuery: string) => void; 341: /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */ 342: onCancel: () => void; 343: setHighlight: (query: string) => void; 344: // Seed with the previous query (less: / shows last pattern). Mount-fire 345: // of the effect re-scans with the same query — idempotent (same matches, 346: // nearest-ptr, same highlights). User can edit or clear. 347: initialQuery: string; 348: }): React.ReactNode { 349: const { 350: query, 351: cursorOffset 352: } = useSearchInput({ 353: isActive: true, 354: initialQuery, 355: onExit: () => onClose(query), 356: onCancel 357: }); 358: // Index warm-up runs before the query effect so it measures the real 359: // cost — otherwise setSearchQuery fills the cache first and warm 360: // reports ~0ms while the user felt the actual lag. 361: // First / in a transcript session pays the extractSearchText cost. 362: // Subsequent / return 0 immediately (indexWarmed ref in VML). 363: // Transcript is frozen at ctrl+o so the cache stays valid. 364: // Initial 'building' so warmDone is false on mount — the [query] effect 365: // waits for the warm effect's first resolve instead of racing it. With 366: // null initial, warmDone would be true on mount → [query] fires → 367: // setSearchQuery fills cache → warm reports ~0ms while the user felt 368: // the real lag. 369: const [indexStatus, setIndexStatus] = React.useState<'building' | { 370: ms: number; 371: } | null>('building'); 372: React.useEffect(() => { 373: let alive = true; 374: const warm = jumpRef.current?.warmSearchIndex; 375: if (!warm) { 376: setIndexStatus(null); // VML not mounted yet — rare, skip indicator 377: return; 378: } 379: setIndexStatus('building'); 380: warm().then(ms => { 381: if (!alive) return; 382: // <20ms = imperceptible. No point showing "indexed in 3ms". 383: if (ms < 20) { 384: setIndexStatus(null); 385: } else { 386: setIndexStatus({ 387: ms 388: }); 389: setTimeout(() => alive && setIndexStatus(null), 2000); 390: } 391: }); 392: return () => { 393: alive = false; 394: }; 395: // eslint-disable-next-line react-hooks/exhaustive-deps 396: }, []); // mount-only: bar opens once per / 397: // Gate the query effect on warm completion. setHighlight stays instant 398: // (screen-space overlay, no indexing). setSearchQuery (the scan) waits. 399: const warmDone = indexStatus !== 'building'; 400: useEffect(() => { 401: if (!warmDone) return; 402: jumpRef.current?.setSearchQuery(query); 403: setHighlight(query); 404: // eslint-disable-next-line react-hooks/exhaustive-deps 405: }, [query, warmDone]); 406: const off = cursorOffset; 407: const cursorChar = off < query.length ? query[off] : ' '; 408: return <Box borderTopDimColor borderBottom={false} borderLeft={false} borderRight={false} borderStyle="single" marginTop={1} paddingLeft={2} width="100%" 409: // applySearchHighlight scans the whole screen buffer. The query 410: // text rendered here IS on screen — /foo matches its own 'foo' in 411: // the bar. With no content matches that's the ONLY visible match → 412: // gets CURRENT → underlined. noSelect makes searchHighlight.ts:76 413: // skip these cells (same exclusion as gutters). You can't text- 414: // select the bar either; it's transient chrome, fine. 415: noSelect> 416: <Text>/</Text> 417: <Text>{query.slice(0, off)}</Text> 418: <Text inverse>{cursorChar}</Text> 419: {off < query.length && <Text>{query.slice(off + 1)}</Text>} 420: <Box flexGrow={1} /> 421: {indexStatus === 'building' ? <Text dimColor>indexing… </Text> : indexStatus ? <Text dimColor>indexed in {indexStatus.ms}ms </Text> : count === 0 && query ? <Text color="error">no matches </Text> : count > 0 ? 422: // Engine-counted (indexOf on extractSearchText). May drift from 423: // render-count for ghost/phantom messages — badge is a rough 424: // location hint. scanElement gives exact per-message positions 425: // but counting ALL would cost ~1-3ms × matched-messages. 426: <Text dimColor> 427: {current}/{count} 428: {' '} 429: </Text> : null} 430: </Box>; 431: } 432: const TITLE_ANIMATION_FRAMES = ['⠂', '⠐']; 433: const TITLE_STATIC_PREFIX = '✳'; 434: const TITLE_ANIMATION_INTERVAL_MS = 960; 435: /** 436: * Sets the terminal tab title, with an animated prefix glyph while a query 437: * is running. Isolated from REPL so the 960ms animation tick re-renders only 438: * this leaf component (which returns null — pure side-effect) instead of the 439: * entire REPL tree. Before extraction, the tick was ~1 REPL render/sec for 440: * the duration of every turn, dragging PromptInput and friends along. 441: */ 442: function AnimatedTerminalTitle(t0) { 443: const $ = _c(6); 444: const { 445: isAnimating, 446: title, 447: disabled, 448: noPrefix 449: } = t0; 450: const terminalFocused = useTerminalFocus(); 451: const [frame, setFrame] = useState(0); 452: let t1; 453: let t2; 454: if ($[0] !== disabled || $[1] !== isAnimating || $[2] !== noPrefix || $[3] !== terminalFocused) { 455: t1 = () => { 456: if (disabled || noPrefix || !isAnimating || !terminalFocused) { 457: return; 458: } 459: const interval = setInterval(_temp2, TITLE_ANIMATION_INTERVAL_MS, setFrame); 460: return () => clearInterval(interval); 461: }; 462: t2 = [disabled, noPrefix, isAnimating, terminalFocused]; 463: $[0] = disabled; 464: $[1] = isAnimating; 465: $[2] = noPrefix; 466: $[3] = terminalFocused; 467: $[4] = t1; 468: $[5] = t2; 469: } else { 470: t1 = $[4]; 471: t2 = $[5]; 472: } 473: useEffect(t1, t2); 474: const prefix = isAnimating ? TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX : TITLE_STATIC_PREFIX; 475: useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`); 476: return null; 477: } 478: function _temp2(setFrame_0) { 479: return setFrame_0(_temp); 480: } 481: function _temp(f) { 482: return (f + 1) % TITLE_ANIMATION_FRAMES.length; 483: } 484: export type Props = { 485: commands: Command[]; 486: debug: boolean; 487: initialTools: Tool[]; 488: // Initial messages to populate the REPL with 489: initialMessages?: MessageType[]; 490: // Deferred hook messages promise — REPL renders immediately and injects 491: // hook messages when they resolve. Awaited before the first API call. 492: pendingHookMessages?: Promise<HookResultMessage[]>; 493: initialFileHistorySnapshots?: FileHistorySnapshot[]; 494: // Content-replacement records from a resumed session's transcript — used to 495: // reconstruct contentReplacementState so the same results are re-replaced 496: initialContentReplacements?: ContentReplacementRecord[]; 497: // Initial agent context for session resume (name/color set via /rename or /color) 498: initialAgentName?: string; 499: initialAgentColor?: AgentColorName; 500: mcpClients?: MCPServerConnection[]; 501: dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>; 502: autoConnectIdeFlag?: boolean; 503: strictMcpConfig?: boolean; 504: systemPrompt?: string; 505: appendSystemPrompt?: string; 506: // Optional callback invoked before query execution 507: // Called after user message is added to conversation but before API call 508: // Return false to prevent query execution 509: onBeforeQuery?: (input: string, newMessages: MessageType[]) => Promise<boolean>; 510: // Optional callback when a turn completes (model finishes responding) 511: onTurnComplete?: (messages: MessageType[]) => void | Promise<void>; 512: // When true, disables REPL input (hides prompt and prevents message selector) 513: disabled?: boolean; 514: // Optional agent definition to use for the main thread 515: mainThreadAgentDefinition?: AgentDefinition; 516: // When true, disables all slash commands 517: disableSlashCommands?: boolean; 518: // Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks. 519: taskListId?: string; 520: // Remote session config for --remote mode (uses CCR as execution engine) 521: remoteSessionConfig?: RemoteSessionConfig; 522: // Direct connect config for `claude connect` mode (connects to a claude server) 523: directConnectConfig?: DirectConnectConfig; 524: sshSession?: SSHSession; 525: thinkingConfig: ThinkingConfig; 526: }; 527: export type Screen = 'prompt' | 'transcript'; 528: export function REPL({ 529: commands: initialCommands, 530: debug, 531: initialTools, 532: initialMessages, 533: pendingHookMessages, 534: initialFileHistorySnapshots, 535: initialContentReplacements, 536: initialAgentName, 537: initialAgentColor, 538: mcpClients: initialMcpClients, 539: dynamicMcpConfig: initialDynamicMcpConfig, 540: autoConnectIdeFlag, 541: strictMcpConfig = false, 542: systemPrompt: customSystemPrompt, 543: appendSystemPrompt, 544: onBeforeQuery, 545: onTurnComplete, 546: disabled = false, 547: mainThreadAgentDefinition: initialMainThreadAgentDefinition, 548: disableSlashCommands = false, 549: taskListId, 550: remoteSessionConfig, 551: directConnectConfig, 552: sshSession, 553: thinkingConfig 554: }: Props): React.ReactNode { 555: const isRemoteSession = !!remoteSessionConfig; 556: const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []); 557: const moreRightEnabled = useMemo(() => "external" === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), []); 558: const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); 559: const disableMessageActions = feature('MESSAGE_ACTIONS') ? 560: useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []) : false; 561: useEffect(() => { 562: logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`); 563: return () => logForDebugging(`[REPL:unmount] REPL unmounting`); 564: }, [disabled]); 565: const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(initialMainThreadAgentDefinition); 566: const toolPermissionContext = useAppState(s => s.toolPermissionContext); 567: const verbose = useAppState(s => s.verbose); 568: const mcp = useAppState(s => s.mcp); 569: const plugins = useAppState(s => s.plugins); 570: const agentDefinitions = useAppState(s => s.agentDefinitions); 571: const fileHistory = useAppState(s => s.fileHistory); 572: const initialMessage = useAppState(s => s.initialMessage); 573: const queuedCommands = useCommandQueue(); 574: const spinnerTip = useAppState(s => s.spinnerTip); 575: const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks'; 576: const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest); 577: const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest); 578: const teamContext = useAppState(s => s.teamContext); 579: const tasks = useAppState(s => s.tasks); 580: const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions); 581: const elicitation = useAppState(s => s.elicitation); 582: const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice); 583: const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending); 584: const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); 585: const setAppState = useSetAppState(); 586: const viewedLocalAgent = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; 587: const needsBootstrap = isLocalAgentTask(viewedLocalAgent) && viewedLocalAgent.retain && !viewedLocalAgent.diskLoaded; 588: useEffect(() => { 589: if (!viewingAgentTaskId || !needsBootstrap) return; 590: const taskId = viewingAgentTaskId; 591: void getAgentTranscript(asAgentId(taskId)).then(result => { 592: setAppState(prev => { 593: const t = prev.tasks[taskId]; 594: if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev; 595: const live = t.messages ?? []; 596: const liveUuids = new Set(live.map(m => m.uuid)); 597: const diskOnly = result ? result.messages.filter(m => !liveUuids.has(m.uuid)) : []; 598: return { 599: ...prev, 600: tasks: { 601: ...prev.tasks, 602: [taskId]: { 603: ...t, 604: messages: [...diskOnly, ...live], 605: diskLoaded: true 606: } 607: } 608: }; 609: }); 610: }); 611: }, [viewingAgentTaskId, needsBootstrap, setAppState]); 612: const store = useAppStateStore(); 613: const terminal = useTerminalNotification(); 614: const mainLoopModel = useMainLoopModel(); 615: const [localCommands, setLocalCommands] = useState(initialCommands); 616: useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands); 617: const proactiveActive = React.useSyncExternalStore(proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE); 618: const isBriefOnly = useAppState(s => s.isBriefOnly); 619: const localTools = useMemo(() => getTools(toolPermissionContext), [toolPermissionContext, proactiveActive, isBriefOnly]); 620: useKickOffCheckAndDisableBypassPermissionsIfNeeded(); 621: useKickOffCheckAndDisableAutoModeIfNeeded(); 622: const [dynamicMcpConfig, setDynamicMcpConfig] = useState<Record<string, ScopedMcpServerConfig> | undefined>(initialDynamicMcpConfig); 623: const onChangeDynamicMcpConfig = useCallback((config: Record<string, ScopedMcpServerConfig>) => { 624: setDynamicMcpConfig(config); 625: }, [setDynamicMcpConfig]); 626: const [screen, setScreen] = useState<Screen>('prompt'); 627: const [showAllInTranscript, setShowAllInTranscript] = useState(false); 628: const [dumpMode, setDumpMode] = useState(false); 629: const [editorStatus, setEditorStatus] = useState(''); 630: // Incremented on transcript exit. Async v-render captures this at start; 631: // each status write no-ops if stale (user left transcript mid-render — 632: // the stable setState would otherwise stamp a ghost toast into the next 633: // session). Also clears any pending 4s auto-clear. 634: const editorGenRef = useRef(0); 635: const editorTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); 636: const editorRenderingRef = useRef(false); 637: const { 638: addNotification, 639: removeNotification 640: } = useNotifications(); 641: // eslint-disable-next-line prefer-const 642: let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP; 643: const mcpClients = useMergedClients(initialMcpClients, mcp.clients); 644: // IDE integration 645: const [ideSelection, setIDESelection] = useState<IDESelection | undefined>(undefined); 646: const [ideToInstallExtension, setIDEToInstallExtension] = useState<IdeType | null>(null); 647: const [ideInstallationStatus, setIDEInstallationStatus] = useState<IDEExtensionInstallationStatus | null>(null); 648: const [showIdeOnboarding, setShowIdeOnboarding] = useState(false); 649: // Dead code elimination: model switch callout state (ant-only) 650: const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => { 651: if ("external" === 'ant') { 652: return shouldShowAntModelSwitch(); 653: } 654: return false; 655: }); 656: const [showEffortCallout, setShowEffortCallout] = useState(() => shouldShowEffortCallout(mainLoopModel)); 657: const showRemoteCallout = useAppState(s => s.showRemoteCallout); 658: const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => shouldShowDesktopUpsellStartup()); 659: useModelMigrationNotifications(); 660: useCanSwitchToExistingSubscription(); 661: useIDEStatusIndicator({ 662: ideSelection, 663: mcpClients, 664: ideInstallationStatus 665: }); 666: useMcpConnectivityStatus({ 667: mcpClients 668: }); 669: useAutoModeUnavailableNotification(); 670: usePluginInstallationStatus(); 671: usePluginAutoupdateNotification(); 672: useSettingsErrors(); 673: useRateLimitWarningNotification(mainLoopModel); 674: useFastModeNotification(); 675: useDeprecationWarningNotification(mainLoopModel); 676: useNpmDeprecationNotification(); 677: useAntOrgWarningNotification(); 678: useInstallMessages(); 679: useChromeExtensionNotification(); 680: useOfficialMarketplaceNotification(); 681: useLspInitializationNotification(); 682: useTeammateLifecycleNotification(); 683: const { 684: recommendation: lspRecommendation, 685: handleResponse: handleLspResponse 686: } = useLspPluginRecommendation(); 687: const { 688: recommendation: hintRecommendation, 689: handleResponse: handleHintResponse 690: } = useClaudeCodeHintRecommendation(); 691: const combinedInitialTools = useMemo(() => { 692: return [...localTools, ...initialTools]; 693: }, [localTools, initialTools]); 694: useManagePlugins({ 695: enabled: !isRemoteSession 696: }); 697: const tasksV2 = useTasksV2WithCollapseEffect(); 698: useEffect(() => { 699: if (isRemoteSession) return; 700: void performStartupChecks(setAppState); 701: }, [setAppState, isRemoteSession]); 702: usePromptsFromClaudeInChrome(isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, toolPermissionContext.mode); 703: useSwarmInitialization(setAppState, initialMessages, { 704: enabled: !isRemoteSession 705: }); 706: const mergedTools = useMergedTools(combinedInitialTools, mcp.tools, toolPermissionContext); 707: const { 708: tools, 709: allowedAgentTypes 710: } = useMemo(() => { 711: if (!mainThreadAgentDefinition) { 712: return { 713: tools: mergedTools, 714: allowedAgentTypes: undefined as string[] | undefined 715: }; 716: } 717: const resolved = resolveAgentTools(mainThreadAgentDefinition, mergedTools, false, true); 718: return { 719: tools: resolved.resolvedTools, 720: allowedAgentTypes: resolved.allowedAgentTypes 721: }; 722: }, [mainThreadAgentDefinition, mergedTools]); 723: const commandsWithPlugins = useMergedCommands(localCommands, plugins.commands as Command[]); 724: const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands as Command[]); 725: const commands = useMemo(() => disableSlashCommands ? [] : mergedCommands, [disableSlashCommands, mergedCommands]); 726: useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients); 727: useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection); 728: const [streamMode, setStreamMode] = useState<SpinnerMode>('responding'); 729: const streamModeRef = useRef(streamMode); 730: streamModeRef.current = streamMode; 731: const [streamingToolUses, setStreamingToolUses] = useState<StreamingToolUse[]>([]); 732: const [streamingThinking, setStreamingThinking] = useState<StreamingThinking | null>(null); 733: useEffect(() => { 734: if (streamingThinking && !streamingThinking.isStreaming && streamingThinking.streamingEndedAt) { 735: const elapsed = Date.now() - streamingThinking.streamingEndedAt; 736: const remaining = 30000 - elapsed; 737: if (remaining > 0) { 738: const timer = setTimeout(setStreamingThinking, remaining, null); 739: return () => clearTimeout(timer); 740: } else { 741: setStreamingThinking(null); 742: } 743: } 744: }, [streamingThinking]); 745: const [abortController, setAbortController] = useState<AbortController | null>(null); 746: const abortControllerRef = useRef<AbortController | null>(null); 747: abortControllerRef.current = abortController; 748: const sendBridgeResultRef = useRef<() => void>(() => {}); 749: const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}); 750: const scrollRef = useRef<ScrollBoxHandle>(null); 751: const modalScrollRef = useRef<ScrollBoxHandle>(null); 752: const lastUserScrollTsRef = useRef(0); 753: const queryGuard = React.useRef(new QueryGuard()).current; 754: const isQueryActive = React.useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot); 755: const [isExternalLoading, setIsExternalLoadingRaw] = React.useState(remoteSessionConfig?.hasInitialPrompt ?? false); 756: const isLoading = isQueryActive || isExternalLoading; 757: const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState<string | undefined>(undefined); 758: const userInputBaselineRef = React.useRef(0); 759: const userMessagePendingRef = React.useRef(false); 760: const loadingStartTimeRef = React.useRef<number>(0); 761: const totalPausedMsRef = React.useRef(0); 762: const pauseStartTimeRef = React.useRef<number | null>(null); 763: const resetTimingRefs = React.useCallback(() => { 764: loadingStartTimeRef.current = Date.now(); 765: totalPausedMsRef.current = 0; 766: pauseStartTimeRef.current = null; 767: }, []); 768: const wasQueryActiveRef = React.useRef(false); 769: if (isQueryActive && !wasQueryActiveRef.current) { 770: resetTimingRefs(); 771: } 772: wasQueryActiveRef.current = isQueryActive; 773: const setIsExternalLoading = React.useCallback((value: boolean) => { 774: setIsExternalLoadingRaw(value); 775: if (value) resetTimingRefs(); 776: }, [resetTimingRefs]); 777: const swarmStartTimeRef = React.useRef<number | null>(null); 778: const swarmBudgetInfoRef = React.useRef<{ 779: tokens: number; 780: limit: number; 781: nudges: number; 782: } | undefined>(undefined); 783: const focusedInputDialogRef = React.useRef<ReturnType<typeof getFocusedInputDialog>>(undefined); 784: const PROMPT_SUPPRESSION_MS = 1500; 785: const [isPromptInputActive, setIsPromptInputActive] = React.useState(false); 786: const [autoUpdaterResult, setAutoUpdaterResult] = useState<AutoUpdaterResult | null>(null); 787: useEffect(() => { 788: if (autoUpdaterResult?.notifications) { 789: autoUpdaterResult.notifications.forEach(notification => { 790: addNotification({ 791: key: 'auto-updater-notification', 792: text: notification, 793: priority: 'low' 794: }); 795: }); 796: } 797: }, [autoUpdaterResult, addNotification]); 798: useEffect(() => { 799: if (isFullscreenEnvEnabled()) { 800: void maybeGetTmuxMouseHint().then(hint => { 801: if (hint) { 802: addNotification({ 803: key: 'tmux-mouse-hint', 804: text: hint, 805: priority: 'low' 806: }); 807: } 808: }); 809: } 810: }, []); 811: const [showUndercoverCallout, setShowUndercoverCallout] = useState(false); 812: useEffect(() => { 813: if ("external" === 'ant') { 814: void (async () => { 815: const { 816: isInternalModelRepo 817: } = await import('../utils/commitAttribution.js'); 818: await isInternalModelRepo(); 819: const { 820: shouldShowUndercoverAutoNotice 821: } = await import('../utils/undercover.js'); 822: if (shouldShowUndercoverAutoNotice()) { 823: setShowUndercoverCallout(true); 824: } 825: })(); 826: } 827: }, []); 828: const [toolJSX, setToolJSXInternal] = useState<{ 829: jsx: React.ReactNode | null; 830: shouldHidePromptInput: boolean; 831: shouldContinueAnimation?: true; 832: showSpinner?: boolean; 833: isLocalJSXCommand?: boolean; 834: isImmediate?: boolean; 835: } | null>(null); 836: const localJSXCommandRef = useRef<{ 837: jsx: React.ReactNode | null; 838: shouldHidePromptInput: boolean; 839: shouldContinueAnimation?: true; 840: showSpinner?: boolean; 841: isLocalJSXCommand: true; 842: } | null>(null); 843: const setToolJSX = useCallback((args: { 844: jsx: React.ReactNode | null; 845: shouldHidePromptInput: boolean; 846: shouldContinueAnimation?: true; 847: showSpinner?: boolean; 848: isLocalJSXCommand?: boolean; 849: clearLocalJSX?: boolean; 850: } | null) => { 851: if (args?.isLocalJSXCommand) { 852: const { 853: clearLocalJSX: _, 854: ...rest 855: } = args; 856: localJSXCommandRef.current = { 857: ...rest, 858: isLocalJSXCommand: true 859: }; 860: setToolJSXInternal(rest); 861: return; 862: } 863: if (localJSXCommandRef.current) { 864: if (args?.clearLocalJSX) { 865: localJSXCommandRef.current = null; 866: setToolJSXInternal(null); 867: return; 868: } 869: return; 870: } 871: if (args?.clearLocalJSX) { 872: setToolJSXInternal(null); 873: return; 874: } 875: setToolJSXInternal(args); 876: }, []); 877: const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState<ToolUseConfirm[]>([]); 878: const [permissionStickyFooter, setPermissionStickyFooter] = useState<React.ReactNode | null>(null); 879: const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = useState<Array<{ 880: hostPattern: NetworkHostPattern; 881: resolvePromise: (allowConnection: boolean) => void; 882: }>>([]); 883: const [promptQueue, setPromptQueue] = useState<Array<{ 884: request: PromptRequest; 885: title: string; 886: toolInputSummary?: string | null; 887: resolve: (response: PromptResponse) => void; 888: reject: (error: Error) => void; 889: }>>([]); 890: const sandboxBridgeCleanupRef = useRef<Map<string, Array<() => void>>>(new Map()); 891: const terminalTitleFromRename = useAppState(s => s.settings.terminalTitleFromRename) !== false; 892: const sessionTitle = terminalTitleFromRename ? getCurrentSessionTitle(getSessionId()) : undefined; 893: const [haikuTitle, setHaikuTitle] = useState<string>(); 894: const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0); 895: const agentTitle = mainThreadAgentDefinition?.agentType; 896: const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code'; 897: const isWaitingForApproval = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || pendingWorkerRequest || pendingSandboxRequest; 898: const isShowingLocalJSXCommand = toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null; 899: const titleIsAnimating = isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand; 900: useEffect(() => { 901: if (isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand) { 902: startPreventSleep(); 903: return () => stopPreventSleep(); 904: } 905: }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]); 906: const sessionStatus: TabStatusKind = isWaitingForApproval || isShowingLocalJSXCommand ? 'waiting' : isLoading ? 'busy' : 'idle'; 907: const waitingFor = sessionStatus !== 'waiting' ? undefined : toolUseConfirmQueue.length > 0 ? `approve ${toolUseConfirmQueue[0]!.tool.name}` : pendingWorkerRequest ? 'worker request' : pendingSandboxRequest ? 'sandbox request' : isShowingLocalJSXCommand ? 'dialog open' : 'input needed'; 908: useEffect(() => { 909: if (feature('BG_SESSIONS')) { 910: void updateSessionActivity({ 911: status: sessionStatus, 912: waitingFor 913: }); 914: } 915: }, [sessionStatus, waitingFor]); 916: const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false); 917: const showStatusInTerminalTab = tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false); 918: useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus); 919: useEffect(() => { 920: registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue); 921: return () => unregisterLeaderToolUseConfirmQueue(); 922: }, [setToolUseConfirmQueue]); 923: const [messages, rawSetMessages] = useState<MessageType[]>(initialMessages ?? []); 924: const messagesRef = useRef(messages); 925: const idleHintShownRef = useRef<string | false>(false); 926: const setMessages = useCallback((action: React.SetStateAction<MessageType[]>) => { 927: const prev = messagesRef.current; 928: const next = typeof action === 'function' ? action(messagesRef.current) : action; 929: messagesRef.current = next; 930: if (next.length < userInputBaselineRef.current) { 931: userInputBaselineRef.current = 0; 932: } else if (next.length > prev.length && userMessagePendingRef.current) { 933: const delta = next.length - prev.length; 934: const added = prev.length === 0 || next[0] === prev[0] ? next.slice(-delta) : next.slice(0, delta); 935: if (added.some(isHumanTurn)) { 936: userMessagePendingRef.current = false; 937: } else { 938: userInputBaselineRef.current = next.length; 939: } 940: } 941: rawSetMessages(next); 942: }, []); 943: const setUserInputOnProcessing = useCallback((input: string | undefined) => { 944: if (input !== undefined) { 945: userInputBaselineRef.current = messagesRef.current.length; 946: userMessagePendingRef.current = true; 947: } else { 948: userMessagePendingRef.current = false; 949: } 950: setUserInputOnProcessingRaw(input); 951: }, []); 952: const { 953: dividerIndex, 954: dividerYRef, 955: onScrollAway, 956: onRepin, 957: jumpToNew, 958: shiftDivider 959: } = useUnseenDivider(messages.length); 960: if (feature('AWAY_SUMMARY')) { 961: useAwaySummary(messages, setMessages, isLoading); 962: } 963: const [cursor, setCursor] = useState<MessageActionsState | null>(null); 964: const cursorNavRef = useRef<MessageActionsNav | null>(null); 965: const unseenDivider = useMemo(() => computeUnseenDivider(messages, dividerIndex), 966: [dividerIndex, messages.length]); 967: const repinScroll = useCallback(() => { 968: scrollRef.current?.scrollToBottom(); 969: onRepin(); 970: setCursor(null); 971: }, [onRepin, setCursor]); 972: const lastMsg = messages.at(-1); 973: const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg); 974: useEffect(() => { 975: if (lastMsgIsHuman) { 976: repinScroll(); 977: } 978: }, [lastMsgIsHuman, lastMsg, repinScroll]); 979: const { 980: maybeLoadOlder 981: } = feature('KAIROS') ? 982: useAssistantHistory({ 983: config: remoteSessionConfig, 984: setMessages, 985: scrollRef, 986: onPrepend: shiftDivider 987: }) : HISTORY_STUB; 988: const composedOnScroll = useCallback((sticky: boolean, handle: ScrollBoxHandle) => { 989: lastUserScrollTsRef.current = Date.now(); 990: if (sticky) { 991: onRepin(); 992: } else { 993: onScrollAway(handle); 994: if (feature('KAIROS')) maybeLoadOlder(handle); 995: if (feature('BUDDY')) { 996: setAppState(prev => prev.companionReaction === undefined ? prev : { 997: ...prev, 998: companionReaction: undefined 999: }); 1000: } 1001: } 1002: }, [onRepin, onScrollAway, maybeLoadOlder, setAppState]); 1003: const awaitPendingHooks = useDeferredHookMessages(pendingHookMessages, setMessages); 1004: const deferredMessages = useDeferredValue(messages); 1005: const deferredBehind = messages.length - deferredMessages.length; 1006: if (deferredBehind > 0) { 1007: logForDebugging(`[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`); 1008: } 1009: const [frozenTranscriptState, setFrozenTranscriptState] = useState<{ 1010: messagesLength: number; 1011: streamingToolUsesLength: number; 1012: } | null>(null); 1013: const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput()); 1014: const inputValueRef = useRef(inputValue); 1015: inputValueRef.current = inputValue; 1016: const insertTextRef = useRef<{ 1017: insert: (text: string) => void; 1018: setInputWithCursor: (value: string, cursor: number) => void; 1019: cursorOffset: number; 1020: } | null>(null); 1021: const setInputValue = useCallback((value: string) => { 1022: if (trySuggestBgPRIntercept(inputValueRef.current, value)) return; 1023: if (inputValueRef.current === '' && value !== '' && Date.now() - lastUserScrollTsRef.current >= RECENT_SCROLL_REPIN_WINDOW_MS) { 1024: repinScroll(); 1025: } 1026: // Sync ref immediately (like setMessages) so callers that read 1027: // inputValueRef before React commits — e.g. the auto-restore finally 1028: // block's `=== ''` guard — see the fresh value, not the stale render. 1029: inputValueRef.current = value; 1030: setInputValueRaw(value); 1031: setIsPromptInputActive(value.trim().length > 0); 1032: }, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept]); 1033: useEffect(() => { 1034: if (inputValue.trim().length === 0) return; 1035: const timer = setTimeout(setIsPromptInputActive, PROMPT_SUPPRESSION_MS, false); 1036: return () => clearTimeout(timer); 1037: }, [inputValue]); 1038: const [inputMode, setInputMode] = useState<PromptInputMode>('prompt'); 1039: const [stashedPrompt, setStashedPrompt] = useState<{ 1040: text: string; 1041: cursorOffset: number; 1042: pastedContents: Record<number, PastedContent>; 1043: } | undefined>(); 1044: const handleRemoteInit = useCallback((remoteSlashCommands: string[]) => { 1045: const remoteCommandSet = new Set(remoteSlashCommands); 1046: setLocalCommands(prev => prev.filter(cmd => remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd))); 1047: }, [setLocalCommands]); 1048: const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState<Set<string>>(new Set()); 1049: const hasInterruptibleToolInProgressRef = useRef(false); 1050: const remoteSession = useRemoteSession({ 1051: config: remoteSessionConfig, 1052: setMessages, 1053: setIsLoading: setIsExternalLoading, 1054: onInit: handleRemoteInit, 1055: setToolUseConfirmQueue, 1056: tools: combinedInitialTools, 1057: setStreamingToolUses, 1058: setStreamMode, 1059: setInProgressToolUseIDs 1060: }); 1061: const directConnect = useDirectConnect({ 1062: config: directConnectConfig, 1063: setMessages, 1064: setIsLoading: setIsExternalLoading, 1065: setToolUseConfirmQueue, 1066: tools: combinedInitialTools 1067: }); 1068: const sshRemote = useSSHSession({ 1069: session: sshSession, 1070: setMessages, 1071: setIsLoading: setIsExternalLoading, 1072: setToolUseConfirmQueue, 1073: tools: combinedInitialTools 1074: }); 1075: const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession; 1076: const [pastedContents, setPastedContents] = useState<Record<number, PastedContent>>({}); 1077: const [submitCount, setSubmitCount] = useState(0); 1078: const responseLengthRef = useRef(0); 1079: const apiMetricsRef = useRef<Array<{ 1080: ttftMs: number; 1081: firstTokenTime: number; 1082: lastTokenTime: number; 1083: responseLengthBaseline: number; 1084: endResponseLength: number; 1085: }>>([]); 1086: const setResponseLength = useCallback((f: (prev: number) => number) => { 1087: const prev = responseLengthRef.current; 1088: responseLengthRef.current = f(prev); 1089: if (responseLengthRef.current > prev) { 1090: const entries = apiMetricsRef.current; 1091: if (entries.length > 0) { 1092: const lastEntry = entries.at(-1)!; 1093: lastEntry.lastTokenTime = Date.now(); 1094: lastEntry.endResponseLength = responseLengthRef.current; 1095: } 1096: } 1097: }, []); 1098: const [streamingText, setStreamingText] = useState<string | null>(null); 1099: const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false; 1100: const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug(); 1101: const onStreamingText = useCallback((f: (current: string | null) => string | null) => { 1102: if (!showStreamingText) return; 1103: setStreamingText(f); 1104: }, [showStreamingText]); 1105: const visibleStreamingText = streamingText && showStreamingText ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null : null; 1106: const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0); 1107: const [spinnerMessage, setSpinnerMessage] = useState<string | null>(null); 1108: const [spinnerColor, setSpinnerColor] = useState<keyof Theme | null>(null); 1109: const [spinnerShimmerColor, setSpinnerShimmerColor] = useState<keyof Theme | null>(null); 1110: const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false); 1111: const [messageSelectorPreselect, setMessageSelectorPreselect] = useState<UserMessage | undefined>(undefined); 1112: const [showCostDialog, setShowCostDialog] = useState(false); 1113: const [conversationId, setConversationId] = useState(randomUUID()); 1114: const [idleReturnPending, setIdleReturnPending] = useState<{ 1115: input: string; 1116: idleMinutes: number; 1117: } | null>(null); 1118: const skipIdleCheckRef = useRef(false); 1119: const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime); 1120: lastQueryCompletionTimeRef.current = lastQueryCompletionTime; 1121: const [contentReplacementStateRef] = useState(() => ({ 1122: current: provisionContentReplacementState(initialMessages, initialContentReplacements) 1123: })); 1124: const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold); 1125: const [vimMode, setVimMode] = useState<VimMode>('INSERT'); 1126: const [showBashesDialog, setShowBashesDialog] = useState<string | boolean>(false); 1127: const [isSearchingHistory, setIsSearchingHistory] = useState(false); 1128: const [isHelpOpen, setIsHelpOpen] = useState(false); 1129: useEffect(() => { 1130: if (ultraplanPendingChoice && showBashesDialog) { 1131: setShowBashesDialog(false); 1132: } 1133: }, [ultraplanPendingChoice, showBashesDialog]); 1134: const isTerminalFocused = useTerminalFocus(); 1135: const terminalFocusRef = useRef(isTerminalFocused); 1136: terminalFocusRef.current = isTerminalFocused; 1137: const [theme] = useTheme(); 1138: const tipPickedThisTurnRef = React.useRef(false); 1139: const pickNewSpinnerTip = useCallback(() => { 1140: if (tipPickedThisTurnRef.current) return; 1141: tipPickedThisTurnRef.current = true; 1142: const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current); 1143: for (const tool of extractBashToolsFromMessages(newMessages)) { 1144: bashTools.current.add(tool); 1145: } 1146: bashToolsProcessedIdx.current = messagesRef.current.length; 1147: void getTipToShowOnSpinner({ 1148: theme, 1149: readFileState: readFileState.current, 1150: bashTools: bashTools.current 1151: }).then(async tip => { 1152: if (tip) { 1153: const content = await tip.content({ 1154: theme 1155: }); 1156: setAppState(prev => ({ 1157: ...prev, 1158: spinnerTip: content 1159: })); 1160: recordShownTip(tip); 1161: } else { 1162: setAppState(prev => { 1163: if (prev.spinnerTip === undefined) return prev; 1164: return { 1165: ...prev, 1166: spinnerTip: undefined 1167: }; 1168: }); 1169: } 1170: }); 1171: }, [setAppState, theme]); 1172: const resetLoadingState = useCallback(() => { 1173: setIsExternalLoading(false); 1174: setUserInputOnProcessing(undefined); 1175: responseLengthRef.current = 0; 1176: apiMetricsRef.current = []; 1177: setStreamingText(null); 1178: setStreamingToolUses([]); 1179: setSpinnerMessage(null); 1180: setSpinnerColor(null); 1181: setSpinnerShimmerColor(null); 1182: pickNewSpinnerTip(); 1183: endInteractionSpan(); 1184: clearSpeculativeChecks(); 1185: }, [pickNewSpinnerTip]); 1186: const hasRunningTeammates = useMemo(() => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'), [tasks]); 1187: useEffect(() => { 1188: if (!hasRunningTeammates && swarmStartTimeRef.current !== null) { 1189: const totalMs = Date.now() - swarmStartTimeRef.current; 1190: const deferredBudget = swarmBudgetInfoRef.current; 1191: swarmStartTimeRef.current = null; 1192: swarmBudgetInfoRef.current = undefined; 1193: setMessages(prev => [...prev, createTurnDurationMessage(totalMs, deferredBudget, 1194: count(prev, isLoggableMessage))]); 1195: } 1196: }, [hasRunningTeammates, setMessages]); 1197: const safeYoloMessageShownRef = useRef(false); 1198: useEffect(() => { 1199: if (feature('TRANSCRIPT_CLASSIFIER')) { 1200: if (toolPermissionContext.mode !== 'auto') { 1201: safeYoloMessageShownRef.current = false; 1202: return; 1203: } 1204: if (safeYoloMessageShownRef.current) return; 1205: const config = getGlobalConfig(); 1206: const count = config.autoPermissionsNotificationCount ?? 0; 1207: if (count >= 3) return; 1208: const timer = setTimeout((ref, setMessages) => { 1209: ref.current = true; 1210: saveGlobalConfig(prev => { 1211: const prevCount = prev.autoPermissionsNotificationCount ?? 0; 1212: if (prevCount >= 3) return prev; 1213: return { 1214: ...prev, 1215: autoPermissionsNotificationCount: prevCount + 1 1216: }; 1217: }); 1218: setMessages(prev => [...prev, createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning')]); 1219: }, 800, safeYoloMessageShownRef, setMessages); 1220: return () => clearTimeout(timer); 1221: } 1222: }, [toolPermissionContext.mode, setMessages]); 1223: const worktreeTipShownRef = useRef(false); 1224: useEffect(() => { 1225: if (worktreeTipShownRef.current) return; 1226: const wt = getCurrentWorktreeSession(); 1227: if (!wt?.creationDurationMs || wt.usedSparsePaths) return; 1228: if (wt.creationDurationMs < 15_000) return; 1229: worktreeTipShownRef.current = true; 1230: const secs = Math.round(wt.creationDurationMs / 1000); 1231: setMessages(prev => [...prev, createSystemMessage(`Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info')]); 1232: }, [setMessages]); 1233: const onlySleepToolActive = useMemo(() => { 1234: const lastAssistant = messages.findLast(m => m.type === 'assistant'); 1235: if (lastAssistant?.type !== 'assistant') return false; 1236: const inProgressToolUses = lastAssistant.message.content.filter(b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id)); 1237: return inProgressToolUses.length > 0 && inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME); 1238: }, [messages, inProgressToolUseIDs]); 1239: const { 1240: onBeforeQuery: mrOnBeforeQuery, 1241: onTurnComplete: mrOnTurnComplete, 1242: render: mrRender 1243: } = useMoreRight({ 1244: enabled: moreRightEnabled, 1245: setMessages, 1246: inputValue, 1247: setInputValue, 1248: setToolJSX 1249: }); 1250: const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && toolUseConfirmQueue.length === 0 && promptQueue.length === 0 && ( 1251: isLoading || userInputOnProcessing || hasRunningTeammates || 1252: getCommandQueueLength() > 0) && 1253: !pendingWorkerRequest && !onlySleepToolActive && ( 1254: !visibleStreamingText || isBriefOnly); 1255: const hasActivePrompt = toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || workerSandboxPermissions.queue.length > 0; 1256: const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt); 1257: const skillImprovementSurvey = useSkillImprovementSurvey(setMessages); 1258: const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount); 1259: const feedbackSurvey = useMemo(() => ({ 1260: ...feedbackSurveyOriginal, 1261: handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => { 1262: didAutoRunIssueRef.current = false; 1263: const showedTranscriptPrompt = feedbackSurveyOriginal.handleSelect(selected); 1264: if (selected === 'bad' && !showedTranscriptPrompt && shouldAutoRunIssue('feedback_survey_bad')) { 1265: setAutoRunIssueReason('feedback_survey_bad'); 1266: didAutoRunIssueRef.current = true; 1267: } 1268: } 1269: }), [feedbackSurveyOriginal]); 1270: const postCompactSurvey = usePostCompactSurvey(messages, isLoading, hasActivePrompt, { 1271: enabled: !isRemoteSession 1272: }); 1273: const memorySurvey = useMemorySurvey(messages, isLoading, hasActivePrompt, { 1274: enabled: !isRemoteSession 1275: }); 1276: const frustrationDetection = useFrustrationDetection(messages, isLoading, hasActivePrompt, feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed'); 1277: useIDEIntegration({ 1278: autoConnectIdeFlag, 1279: ideToInstallExtension, 1280: setDynamicMcpConfig, 1281: setShowIdeOnboarding, 1282: setIDEInstallationState: setIDEInstallationStatus 1283: }); 1284: useFileHistorySnapshotInit(initialFileHistorySnapshots, fileHistory, fileHistoryState => setAppState(prev => ({ 1285: ...prev, 1286: fileHistory: fileHistoryState 1287: }))); 1288: const resume = useCallback(async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { 1289: const resumeStart = performance.now(); 1290: try { 1291: const messages = deserializeMessages(log.messages); 1292: if (feature('COORDINATOR_MODE')) { 1293: const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); 1294: const warning = coordinatorModule.matchSessionMode(log.mode); 1295: if (warning) { 1296: const { 1297: getAgentDefinitionsWithOverrides, 1298: getActiveAgentsFromList 1299: } = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js'); 1300: getAgentDefinitionsWithOverrides.cache.clear?.(); 1301: const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); 1302: setAppState(prev => ({ 1303: ...prev, 1304: agentDefinitions: { 1305: ...freshAgentDefs, 1306: allAgents: freshAgentDefs.allAgents, 1307: activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents) 1308: } 1309: })); 1310: messages.push(createSystemMessage(warning, 'warning')); 1311: } 1312: } 1313: const sessionEndTimeoutMs = getSessionEndHookTimeoutMs(); 1314: await executeSessionEndHooks('resume', { 1315: getAppState: () => store.getState(), 1316: setAppState, 1317: signal: AbortSignal.timeout(sessionEndTimeoutMs), 1318: timeoutMs: sessionEndTimeoutMs 1319: }); 1320: const hookMessages = await processSessionStartHooks('resume', { 1321: sessionId, 1322: agentType: mainThreadAgentDefinition?.agentType, 1323: model: mainLoopModel 1324: }); 1325: messages.push(...hookMessages); 1326: if (entrypoint === 'fork') { 1327: void copyPlanForFork(log, asSessionId(sessionId)); 1328: } else { 1329: void copyPlanForResume(log, asSessionId(sessionId)); 1330: } 1331: restoreSessionStateFromLog(log, setAppState); 1332: if (log.fileHistorySnapshots) { 1333: void copyFileHistoryForResume(log); 1334: } 1335: const { 1336: agentDefinition: restoredAgent 1337: } = restoreAgentFromSession(log.agentSetting, initialMainThreadAgentDefinition, agentDefinitions); 1338: setMainThreadAgentDefinition(restoredAgent); 1339: setAppState(prev => ({ 1340: ...prev, 1341: agent: restoredAgent?.agentType 1342: })); 1343: setAppState(prev => ({ 1344: ...prev, 1345: standaloneAgentContext: computeStandaloneAgentContext(log.agentName, log.agentColor) 1346: })); 1347: void updateSessionName(log.agentName); 1348: restoreReadFileState(messages, log.projectPath ?? getOriginalCwd()); 1349: resetLoadingState(); 1350: setAbortController(null); 1351: setConversationId(sessionId); 1352: const targetSessionCosts = getStoredSessionCosts(sessionId); 1353: saveCurrentSessionCosts(); 1354: resetCostState(); 1355: switchSession(asSessionId(sessionId), log.fullPath ? dirname(log.fullPath) : null); 1356: const { 1357: renameRecordingForSession 1358: } = await import('../utils/asciicast.js'); 1359: await renameRecordingForSession(); 1360: await resetSessionFilePointer(); 1361: clearSessionMetadata(); 1362: restoreSessionMetadata(log); 1363: haikuTitleAttemptedRef.current = true; 1364: setHaikuTitle(undefined); 1365: if (entrypoint !== 'fork') { 1366: exitRestoredWorktree(); 1367: restoreWorktreeForResume(log.worktreeSession); 1368: adoptResumedSessionFile(); 1369: void restoreRemoteAgentTasks({ 1370: abortController: new AbortController(), 1371: getAppState: () => store.getState(), 1372: setAppState 1373: }); 1374: } else { 1375: const ws = getCurrentWorktreeSession(); 1376: if (ws) saveWorktreeState(ws); 1377: } 1378: if (feature('COORDINATOR_MODE')) { 1379: const { 1380: saveMode 1381: } = require('../utils/sessionStorage.js'); 1382: const { 1383: isCoordinatorMode 1384: } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); 1385: saveMode(isCoordinatorMode() ? 'coordinator' : 'normal'); 1386: } 1387: if (targetSessionCosts) { 1388: setCostStateForRestore(targetSessionCosts); 1389: } 1390: if (contentReplacementStateRef.current && entrypoint !== 'fork') { 1391: contentReplacementStateRef.current = reconstructContentReplacementState(messages, log.contentReplacements ?? []); 1392: } 1393: setMessages(() => messages); 1394: setToolJSX(null); 1395: setInputValue(''); 1396: logEvent('tengu_session_resumed', { 1397: entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1398: success: true, 1399: resume_duration_ms: Math.round(performance.now() - resumeStart) 1400: }); 1401: } catch (error) { 1402: logEvent('tengu_session_resumed', { 1403: entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1404: success: false 1405: }); 1406: throw error; 1407: } 1408: }, [resetLoadingState, setAppState]); 1409: const [initialReadFileState] = useState(() => createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)); 1410: const readFileState = useRef(initialReadFileState); 1411: const bashTools = useRef(new Set<string>()); 1412: const bashToolsProcessedIdx = useRef(0); 1413: const discoveredSkillNamesRef = useRef(new Set<string>()); 1414: const loadedNestedMemoryPathsRef = useRef(new Set<string>()); 1415: const restoreReadFileState = useCallback((messages: MessageType[], cwd: string) => { 1416: const extracted = extractReadFilesFromMessages(messages, cwd, READ_FILE_STATE_CACHE_SIZE); 1417: readFileState.current = mergeFileStateCaches(readFileState.current, extracted); 1418: for (const tool of extractBashToolsFromMessages(messages)) { 1419: bashTools.current.add(tool); 1420: } 1421: }, []); 1422: useEffect(() => { 1423: if (initialMessages && initialMessages.length > 0) { 1424: restoreReadFileState(initialMessages, getOriginalCwd()); 1425: void restoreRemoteAgentTasks({ 1426: abortController: new AbortController(), 1427: getAppState: () => store.getState(), 1428: setAppState 1429: }); 1430: } 1431: }, []); 1432: const { 1433: status: apiKeyStatus, 1434: reverify 1435: } = useApiKeyVerification(); 1436: const [autoRunIssueReason, setAutoRunIssueReason] = useState<AutoRunIssueReason | null>(null); 1437: const didAutoRunIssueRef = useRef(false); 1438: const [exitFlow, setExitFlow] = useState<React.ReactNode>(null); 1439: const [isExiting, setIsExiting] = useState(false); 1440: const showingCostDialog = !isLoading && showCostDialog; 1441: function getFocusedInputDialog(): 'message-selector' | 'sandbox-permission' | 'tool-permission' | 'prompt' | 'worker-sandbox-permission' | 'elicitation' | 'cost' | 'idle-return' | 'init-onboarding' | 'ide-onboarding' | 'model-switch' | 'undercover-callout' | 'effort-callout' | 'remote-callout' | 'lsp-recommendation' | 'plugin-hint' | 'desktop-upsell' | 'ultraplan-choice' | 'ultraplan-launch' | undefined { 1442: if (isExiting || exitFlow) return undefined; 1443: if (isMessageSelectorVisible) return 'message-selector'; 1444: if (isPromptInputActive) return undefined; 1445: if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission'; 1446: const allowDialogsWithAnimation = !toolJSX || toolJSX.shouldContinueAnimation; 1447: if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) return 'tool-permission'; 1448: if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'; 1449: if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) return 'worker-sandbox-permission'; 1450: if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation'; 1451: if (allowDialogsWithAnimation && showingCostDialog) return 'cost'; 1452: if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return'; 1453: if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanPendingChoice) return 'ultraplan-choice'; 1454: if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanLaunchPending) return 'ultraplan-launch'; 1455: if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'; 1456: if ("external" === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch'; 1457: if ("external" === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) return 'undercover-callout'; 1458: if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout'; 1459: if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout'; 1460: if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation'; 1461: if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint'; 1462: if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell'; 1463: return undefined; 1464: } 1465: const focusedInputDialog = getFocusedInputDialog(); 1466: const hasSuppressedDialogs = isPromptInputActive && (sandboxPermissionRequestQueue[0] || toolUseConfirmQueue[0] || promptQueue[0] || workerSandboxPermissions.queue[0] || elicitation.queue[0] || showingCostDialog); 1467: focusedInputDialogRef.current = focusedInputDialog; 1468: useEffect(() => { 1469: if (!isLoading) return; 1470: const isPaused = focusedInputDialog === 'tool-permission'; 1471: const now = Date.now(); 1472: if (isPaused && pauseStartTimeRef.current === null) { 1473: pauseStartTimeRef.current = now; 1474: } else if (!isPaused && pauseStartTimeRef.current !== null) { 1475: totalPausedMsRef.current += now - pauseStartTimeRef.current; 1476: pauseStartTimeRef.current = null; 1477: } 1478: }, [focusedInputDialog, isLoading]); 1479: const prevDialogRef = useRef(focusedInputDialog); 1480: useLayoutEffect(() => { 1481: const was = prevDialogRef.current === 'tool-permission'; 1482: const now = focusedInputDialog === 'tool-permission'; 1483: if (was !== now) repinScroll(); 1484: prevDialogRef.current = focusedInputDialog; 1485: }, [focusedInputDialog, repinScroll]); 1486: function onCancel() { 1487: if (focusedInputDialog === 'elicitation') { 1488: return; 1489: } 1490: logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`); 1491: if (feature('PROACTIVE') || feature('KAIROS')) { 1492: proactiveModule?.pauseProactive(); 1493: } 1494: queryGuard.forceEnd(); 1495: skipIdleCheckRef.current = false; 1496: if (streamingText?.trim()) { 1497: setMessages(prev => [...prev, createAssistantMessage({ 1498: content: streamingText 1499: })]); 1500: } 1501: resetLoadingState(); 1502: if (feature('TOKEN_BUDGET')) { 1503: snapshotOutputTokensForTurn(null); 1504: } 1505: if (focusedInputDialog === 'tool-permission') { 1506: toolUseConfirmQueue[0]?.onAbort(); 1507: setToolUseConfirmQueue([]); 1508: } else if (focusedInputDialog === 'prompt') { 1509: for (const item of promptQueue) { 1510: item.reject(new Error('Prompt cancelled by user')); 1511: } 1512: setPromptQueue([]); 1513: abortController?.abort('user-cancel'); 1514: } else if (activeRemote.isRemoteMode) { 1515: activeRemote.cancelRequest(); 1516: } else { 1517: abortController?.abort('user-cancel'); 1518: } 1519: setAbortController(null); 1520: void mrOnTurnComplete(messagesRef.current, true); 1521: } 1522: const handleQueuedCommandOnCancel = useCallback(() => { 1523: const result = popAllEditable(inputValue, 0); 1524: if (!result) return; 1525: setInputValue(result.text); 1526: setInputMode('prompt'); 1527: if (result.images.length > 0) { 1528: setPastedContents(prev => { 1529: const newContents = { 1530: ...prev 1531: }; 1532: for (const image of result.images) { 1533: newContents[image.id] = image; 1534: } 1535: return newContents; 1536: }); 1537: } 1538: }, [setInputValue, setInputMode, inputValue, setPastedContents]); 1539: const cancelRequestProps = { 1540: setToolUseConfirmQueue, 1541: onCancel, 1542: onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]), 1543: isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog, 1544: screen, 1545: abortSignal: abortController?.signal, 1546: popCommandFromQueue: handleQueuedCommandOnCancel, 1547: vimMode, 1548: isLocalJSXCommand: toolJSX?.isLocalJSXCommand, 1549: isSearchingHistory, 1550: isHelpOpen, 1551: inputMode, 1552: inputValue, 1553: streamMode 1554: }; 1555: useEffect(() => { 1556: const totalCost = getTotalCost(); 1557: if (totalCost >= 5 && !showCostDialog && !haveShownCostDialog) { 1558: logEvent('tengu_cost_threshold_reached', {}); 1559: setHaveShownCostDialog(true); 1560: if (hasConsoleBillingAccess()) { 1561: setShowCostDialog(true); 1562: } 1563: } 1564: }, [messages, showCostDialog, haveShownCostDialog]); 1565: const sandboxAskCallback: SandboxAskCallback = useCallback(async (hostPattern: NetworkHostPattern) => { 1566: if (isAgentSwarmsEnabled() && isSwarmWorker()) { 1567: const requestId = generateSandboxRequestId(); 1568: const sent = await sendSandboxPermissionRequestViaMailbox(hostPattern.host, requestId); 1569: return new Promise(resolveShouldAllowHost => { 1570: if (!sent) { 1571: setSandboxPermissionRequestQueue(prev => [...prev, { 1572: hostPattern, 1573: resolvePromise: resolveShouldAllowHost 1574: }]); 1575: return; 1576: } 1577: registerSandboxPermissionCallback({ 1578: requestId, 1579: host: hostPattern.host, 1580: resolve: resolveShouldAllowHost 1581: }); 1582: setAppState(prev => ({ 1583: ...prev, 1584: pendingSandboxRequest: { 1585: requestId, 1586: host: hostPattern.host 1587: } 1588: })); 1589: }); 1590: } 1591: return new Promise(resolveShouldAllowHost => { 1592: let resolved = false; 1593: function resolveOnce(allow: boolean): void { 1594: if (resolved) return; 1595: resolved = true; 1596: resolveShouldAllowHost(allow); 1597: } 1598: setSandboxPermissionRequestQueue(prev => [...prev, { 1599: hostPattern, 1600: resolvePromise: resolveOnce 1601: }]); 1602: if (feature('BRIDGE_MODE')) { 1603: const bridgeCallbacks = store.getState().replBridgePermissionCallbacks; 1604: if (bridgeCallbacks) { 1605: const bridgeRequestId = randomUUID(); 1606: bridgeCallbacks.sendRequest(bridgeRequestId, SANDBOX_NETWORK_ACCESS_TOOL_NAME, { 1607: host: hostPattern.host 1608: }, randomUUID(), `Allow network connection to ${hostPattern.host}?`); 1609: const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => { 1610: unsubscribe(); 1611: const allow = response.behavior === 'allow'; 1612: setSandboxPermissionRequestQueue(queue => { 1613: queue.filter(item => item.hostPattern.host === hostPattern.host).forEach(item => item.resolvePromise(allow)); 1614: return queue.filter(item => item.hostPattern.host !== hostPattern.host); 1615: }); 1616: const siblingCleanups = sandboxBridgeCleanupRef.current.get(hostPattern.host); 1617: if (siblingCleanups) { 1618: for (const fn of siblingCleanups) { 1619: fn(); 1620: } 1621: sandboxBridgeCleanupRef.current.delete(hostPattern.host); 1622: } 1623: }); 1624: const cleanup = () => { 1625: unsubscribe(); 1626: bridgeCallbacks.cancelRequest(bridgeRequestId); 1627: }; 1628: const existing = sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? []; 1629: existing.push(cleanup); 1630: sandboxBridgeCleanupRef.current.set(hostPattern.host, existing); 1631: } 1632: } 1633: }); 1634: }, [setAppState, store]); 1635: useEffect(() => { 1636: const reason = SandboxManager.getSandboxUnavailableReason(); 1637: if (!reason) return; 1638: if (SandboxManager.isSandboxRequired()) { 1639: process.stderr.write(`\nError: sandbox required but unavailable: ${reason}\n` + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`); 1640: gracefulShutdownSync(1, 'other'); 1641: return; 1642: } 1643: logForDebugging(`sandbox disabled: ${reason}`, { 1644: level: 'warn' 1645: }); 1646: addNotification({ 1647: key: 'sandbox-unavailable', 1648: jsx: <> 1649: <Text color="warning">sandbox disabled</Text> 1650: <Text dimColor> · /sandbox</Text> 1651: </>, 1652: priority: 'medium' 1653: }); 1654: }, [addNotification]); 1655: if (SandboxManager.isSandboxingEnabled()) { 1656: SandboxManager.initialize(sandboxAskCallback).catch(err => { 1657: process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`); 1658: gracefulShutdownSync(1, 'other'); 1659: }); 1660: } 1661: const setToolPermissionContext = useCallback((context: ToolPermissionContext, options?: { 1662: preserveMode?: boolean; 1663: }) => { 1664: setAppState(prev => ({ 1665: ...prev, 1666: toolPermissionContext: { 1667: ...context, 1668: mode: options?.preserveMode ? prev.toolPermissionContext.mode : context.mode 1669: } 1670: })); 1671: setImmediate(setToolUseConfirmQueue => { 1672: setToolUseConfirmQueue(currentQueue => { 1673: currentQueue.forEach(item => { 1674: void item.recheckPermission(); 1675: }); 1676: return currentQueue; 1677: }); 1678: }, setToolUseConfirmQueue); 1679: }, [setAppState, setToolUseConfirmQueue]); 1680: useEffect(() => { 1681: registerLeaderSetToolPermissionContext(setToolPermissionContext); 1682: return () => unregisterLeaderSetToolPermissionContext(); 1683: }, [setToolPermissionContext]); 1684: const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext); 1685: const requestPrompt = useCallback((title: string, toolInputSummary?: string | null) => (request: PromptRequest): Promise<PromptResponse> => new Promise<PromptResponse>((resolve, reject) => { 1686: setPromptQueue(prev => [...prev, { 1687: request, 1688: title, 1689: toolInputSummary, 1690: resolve, 1691: reject 1692: }]); 1693: }), []); 1694: const getToolUseContext = useCallback((messages: MessageType[], newMessages: MessageType[], abortController: AbortController, mainLoopModel: string): ProcessUserInputContext => { 1695: const s = store.getState(); 1696: const computeTools = () => { 1697: const state = store.getState(); 1698: const assembled = assembleToolPool(state.toolPermissionContext, state.mcp.tools); 1699: const merged = mergeAndFilterTools(combinedInitialTools, assembled, state.toolPermissionContext.mode); 1700: if (!mainThreadAgentDefinition) return merged; 1701: return resolveAgentTools(mainThreadAgentDefinition, merged, false, true).resolvedTools; 1702: }; 1703: return { 1704: abortController, 1705: options: { 1706: commands, 1707: tools: computeTools(), 1708: debug, 1709: verbose: s.verbose, 1710: mainLoopModel, 1711: thinkingConfig: s.thinkingEnabled !== false ? thinkingConfig : { 1712: type: 'disabled' 1713: }, 1714: mcpClients: mergeClients(initialMcpClients, s.mcp.clients), 1715: mcpResources: s.mcp.resources, 1716: ideInstallationStatus: ideInstallationStatus, 1717: isNonInteractiveSession: false, 1718: dynamicMcpConfig, 1719: theme, 1720: agentDefinitions: allowedAgentTypes ? { 1721: ...s.agentDefinitions, 1722: allowedAgentTypes 1723: } : s.agentDefinitions, 1724: customSystemPrompt, 1725: appendSystemPrompt, 1726: refreshTools: computeTools 1727: }, 1728: getAppState: () => store.getState(), 1729: setAppState, 1730: messages, 1731: setMessages, 1732: updateFileHistoryState(updater: (prev: FileHistoryState) => FileHistoryState) { 1733: setAppState(prev => { 1734: const updated = updater(prev.fileHistory); 1735: if (updated === prev.fileHistory) return prev; 1736: return { 1737: ...prev, 1738: fileHistory: updated 1739: }; 1740: }); 1741: }, 1742: updateAttributionState(updater: (prev: AttributionState) => AttributionState) { 1743: setAppState(prev => { 1744: const updated = updater(prev.attribution); 1745: if (updated === prev.attribution) return prev; 1746: return { 1747: ...prev, 1748: attribution: updated 1749: }; 1750: }); 1751: }, 1752: openMessageSelector: () => { 1753: if (!disabled) { 1754: setIsMessageSelectorVisible(true); 1755: } 1756: }, 1757: onChangeAPIKey: reverify, 1758: readFileState: readFileState.current, 1759: setToolJSX, 1760: addNotification, 1761: appendSystemMessage: msg => setMessages(prev => [...prev, msg]), 1762: sendOSNotification: opts => { 1763: void sendNotification(opts, terminal); 1764: }, 1765: onChangeDynamicMcpConfig, 1766: onInstallIDEExtension: setIDEToInstallExtension, 1767: nestedMemoryAttachmentTriggers: new Set<string>(), 1768: loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, 1769: dynamicSkillDirTriggers: new Set<string>(), 1770: discoveredSkillNames: discoveredSkillNamesRef.current, 1771: setResponseLength, 1772: pushApiMetricsEntry: "external" === 'ant' ? (ttftMs: number) => { 1773: const now = Date.now(); 1774: const baseline = responseLengthRef.current; 1775: apiMetricsRef.current.push({ 1776: ttftMs, 1777: firstTokenTime: now, 1778: lastTokenTime: now, 1779: responseLengthBaseline: baseline, 1780: endResponseLength: baseline 1781: }); 1782: } : undefined, 1783: setStreamMode, 1784: onCompactProgress: event => { 1785: switch (event.type) { 1786: case 'hooks_start': 1787: setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER'); 1788: setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER'); 1789: setSpinnerMessage(event.hookType === 'pre_compact' ? 'Running PreCompact hooks\u2026' : event.hookType === 'post_compact' ? 'Running PostCompact hooks\u2026' : 'Running SessionStart hooks\u2026'); 1790: break; 1791: case 'compact_start': 1792: setSpinnerMessage('Compacting conversation'); 1793: break; 1794: case 'compact_end': 1795: setSpinnerMessage(null); 1796: setSpinnerColor(null); 1797: setSpinnerShimmerColor(null); 1798: break; 1799: } 1800: }, 1801: setInProgressToolUseIDs, 1802: setHasInterruptibleToolInProgress: (v: boolean) => { 1803: hasInterruptibleToolInProgressRef.current = v; 1804: }, 1805: resume, 1806: setConversationId, 1807: requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined, 1808: contentReplacementState: contentReplacementStateRef.current 1809: }; 1810: }, [commands, combinedInitialTools, mainThreadAgentDefinition, debug, initialMcpClients, ideInstallationStatus, dynamicMcpConfig, theme, allowedAgentTypes, store, setAppState, reverify, addNotification, setMessages, onChangeDynamicMcpConfig, resume, requestPrompt, disabled, customSystemPrompt, appendSystemPrompt, setConversationId]); 1811: const handleBackgroundQuery = useCallback(() => { 1812: abortController?.abort('background'); 1813: const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification'); 1814: void (async () => { 1815: const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel); 1816: const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]); 1817: const systemPrompt = buildEffectiveSystemPrompt({ 1818: mainThreadAgentDefinition, 1819: toolUseContext, 1820: customSystemPrompt, 1821: defaultSystemPrompt, 1822: appendSystemPrompt 1823: }); 1824: toolUseContext.renderedSystemPrompt = systemPrompt; 1825: const notificationAttachments = await getQueuedCommandAttachments(removedNotifications).catch(() => []); 1826: const notificationMessages = notificationAttachments.map(createAttachmentMessage); 1827: const existingPrompts = new Set<string>(); 1828: for (const m of messagesRef.current) { 1829: if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { 1830: existingPrompts.add(m.attachment.prompt); 1831: } 1832: } 1833: const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt))); 1834: startBackgroundSession({ 1835: messages: [...messagesRef.current, ...uniqueNotifications], 1836: queryParams: { 1837: systemPrompt, 1838: userContext, 1839: systemContext, 1840: canUseTool, 1841: toolUseContext, 1842: querySource: getQuerySourceForREPL() 1843: }, 1844: description: terminalTitle, 1845: setAppState, 1846: agentDefinition: mainThreadAgentDefinition 1847: }); 1848: })(); 1849: }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState]); 1850: const { 1851: handleBackgroundSession 1852: } = useSessionBackgrounding({ 1853: setMessages, 1854: setIsLoading: setIsExternalLoading, 1855: resetLoadingState, 1856: setAbortController, 1857: onBackgroundQuery: handleBackgroundQuery 1858: }); 1859: const onQueryEvent = useCallback((event: Parameters<typeof handleMessageFromStream>[0]) => { 1860: handleMessageFromStream(event, newMessage => { 1861: if (isCompactBoundaryMessage(newMessage)) { 1862: if (isFullscreenEnvEnabled()) { 1863: setMessages(old => [...getMessagesAfterCompactBoundary(old, { 1864: includeSnipped: true 1865: }), newMessage]); 1866: } else { 1867: setMessages(() => [newMessage]); 1868: } 1869: setConversationId(randomUUID()); 1870: if (feature('PROACTIVE') || feature('KAIROS')) { 1871: proactiveModule?.setContextBlocked(false); 1872: } 1873: } else if (newMessage.type === 'progress' && isEphemeralToolProgress(newMessage.data.type)) { 1874: setMessages(oldMessages => { 1875: const last = oldMessages.at(-1); 1876: if (last?.type === 'progress' && last.parentToolUseID === newMessage.parentToolUseID && last.data.type === newMessage.data.type) { 1877: const copy = oldMessages.slice(); 1878: copy[copy.length - 1] = newMessage; 1879: return copy; 1880: } 1881: return [...oldMessages, newMessage]; 1882: }); 1883: } else { 1884: setMessages(oldMessages => [...oldMessages, newMessage]); 1885: } 1886: if (feature('PROACTIVE') || feature('KAIROS')) { 1887: if (newMessage.type === 'assistant' && 'isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) { 1888: proactiveModule?.setContextBlocked(true); 1889: } else if (newMessage.type === 'assistant') { 1890: proactiveModule?.setContextBlocked(false); 1891: } 1892: } 1893: }, newContent => { 1894: setResponseLength(length => length + newContent.length); 1895: }, setStreamMode, setStreamingToolUses, tombstonedMessage => { 1896: setMessages(oldMessages => oldMessages.filter(m => m !== tombstonedMessage)); 1897: void removeTranscriptMessage(tombstonedMessage.uuid); 1898: }, setStreamingThinking, metrics => { 1899: const now = Date.now(); 1900: const baseline = responseLengthRef.current; 1901: apiMetricsRef.current.push({ 1902: ...metrics, 1903: firstTokenTime: now, 1904: lastTokenTime: now, 1905: responseLengthBaseline: baseline, 1906: endResponseLength: baseline 1907: }); 1908: }, onStreamingText); 1909: }, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]); 1910: const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, effort?: EffortValue) => { 1911: if (shouldQuery) { 1912: const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients); 1913: void diagnosticTracker.handleQueryStart(freshClients); 1914: const ideClient = getConnectedIdeClient(freshClients); 1915: if (ideClient) { 1916: void closeOpenDiffs(ideClient); 1917: } 1918: } 1919: void maybeMarkProjectOnboardingComplete(); 1920: if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { 1921: const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta); 1922: const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content) : null; 1923: if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) && !text.startsWith(`<${COMMAND_MESSAGE_TAG}>`) && !text.startsWith(`<${COMMAND_NAME_TAG}>`) && !text.startsWith(`<${BASH_INPUT_TAG}>`)) { 1924: haikuTitleAttemptedRef.current = true; 1925: void generateSessionTitle(text, new AbortController().signal).then(title => { 1926: if (title) setHaikuTitle(title);else haikuTitleAttemptedRef.current = false; 1927: }, () => { 1928: haikuTitleAttemptedRef.current = false; 1929: }); 1930: } 1931: } 1932: store.setState(prev => { 1933: const cur = prev.toolPermissionContext.alwaysAllowRules.command; 1934: if (cur === additionalAllowedTools || cur?.length === additionalAllowedTools.length && cur.every((v, i) => v === additionalAllowedTools[i])) { 1935: return prev; 1936: } 1937: return { 1938: ...prev, 1939: toolPermissionContext: { 1940: ...prev.toolPermissionContext, 1941: alwaysAllowRules: { 1942: ...prev.toolPermissionContext.alwaysAllowRules, 1943: command: additionalAllowedTools 1944: } 1945: } 1946: }; 1947: }); 1948: if (!shouldQuery) { 1949: if (newMessages.some(isCompactBoundaryMessage)) { 1950: setConversationId(randomUUID()); 1951: if (feature('PROACTIVE') || feature('KAIROS')) { 1952: proactiveModule?.setContextBlocked(false); 1953: } 1954: } 1955: resetLoadingState(); 1956: setAbortController(null); 1957: return; 1958: } 1959: const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam); 1960: const { 1961: tools: freshTools, 1962: mcpClients: freshMcpClients 1963: } = toolUseContext.options; 1964: if (effort !== undefined) { 1965: const previousGetAppState = toolUseContext.getAppState; 1966: toolUseContext.getAppState = () => ({ 1967: ...previousGetAppState(), 1968: effortValue: effort 1969: }); 1970: } 1971: queryCheckpoint('query_context_loading_start'); 1972: const [,, defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ 1973: checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), 1974: feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) : undefined, getSystemPrompt(freshTools, mainLoopModelParam, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients), getUserContext(), getSystemContext()]); 1975: const userContext = { 1976: ...baseUserContext, 1977: ...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined), 1978: ...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current ? { 1979: terminalFocus: 'The terminal is unfocused \u2014 the user is not actively watching.' 1980: } : {}) 1981: }; 1982: queryCheckpoint('query_context_loading_end'); 1983: const systemPrompt = buildEffectiveSystemPrompt({ 1984: mainThreadAgentDefinition, 1985: toolUseContext, 1986: customSystemPrompt, 1987: defaultSystemPrompt, 1988: appendSystemPrompt 1989: }); 1990: toolUseContext.renderedSystemPrompt = systemPrompt; 1991: queryCheckpoint('query_query_start'); 1992: resetTurnHookDuration(); 1993: resetTurnToolDuration(); 1994: resetTurnClassifierDuration(); 1995: for await (const event of query({ 1996: messages: messagesIncludingNewMessages, 1997: systemPrompt, 1998: userContext, 1999: systemContext, 2000: canUseTool, 2001: toolUseContext, 2002: querySource: getQuerySourceForREPL() 2003: })) { 2004: onQueryEvent(event); 2005: } 2006: if (feature('BUDDY')) { 2007: void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : { 2008: ...prev, 2009: companionReaction: reaction 2010: })); 2011: } 2012: queryCheckpoint('query_end'); 2013: if ("external" === 'ant' && apiMetricsRef.current.length > 0) { 2014: const entries = apiMetricsRef.current; 2015: const ttfts = entries.map(e => e.ttftMs); 2016: const otpsValues = entries.map(e => { 2017: const delta = Math.round((e.endResponseLength - e.responseLengthBaseline) / 4); 2018: const samplingMs = e.lastTokenTime - e.firstTokenTime; 2019: return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0; 2020: }); 2021: const isMultiRequest = entries.length > 1; 2022: const hookMs = getTurnHookDurationMs(); 2023: const hookCount = getTurnHookCount(); 2024: const toolMs = getTurnToolDurationMs(); 2025: const toolCount = getTurnToolCount(); 2026: const classifierMs = getTurnClassifierDurationMs(); 2027: const classifierCount = getTurnClassifierCount(); 2028: const turnMs = Date.now() - loadingStartTimeRef.current; 2029: setMessages(prev => [...prev, createApiMetricsMessage({ 2030: ttftMs: isMultiRequest ? median(ttfts) : ttfts[0]!, 2031: otps: isMultiRequest ? median(otpsValues) : otpsValues[0]!, 2032: isP50: isMultiRequest, 2033: hookDurationMs: hookMs > 0 ? hookMs : undefined, 2034: hookCount: hookCount > 0 ? hookCount : undefined, 2035: turnDurationMs: turnMs > 0 ? turnMs : undefined, 2036: toolDurationMs: toolMs > 0 ? toolMs : undefined, 2037: toolCount: toolCount > 0 ? toolCount : undefined, 2038: classifierDurationMs: classifierMs > 0 ? classifierMs : undefined, 2039: classifierCount: classifierCount > 0 ? classifierCount : undefined, 2040: configWriteCount: getGlobalConfigWriteCount() 2041: })]); 2042: } 2043: resetLoadingState(); 2044: logQueryProfileReport(); 2045: await onTurnComplete?.(messagesRef.current); 2046: }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled]); 2047: const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise<boolean>, input?: string, effort?: EffortValue): Promise<void> => { 2048: if (isAgentSwarmsEnabled()) { 2049: const teamName = getTeamName(); 2050: const agentName = getAgentName(); 2051: if (teamName && agentName) { 2052: void setMemberActive(teamName, agentName, true); 2053: } 2054: } 2055: const thisGeneration = queryGuard.tryStart(); 2056: if (thisGeneration === null) { 2057: logEvent('tengu_concurrent_onquery_detected', {}); 2058: newMessages.filter((m): m is UserMessage => m.type === 'user' && !m.isMeta).map(_ => getContentText(_.message.content)).filter(_ => _ !== null).forEach((msg, i) => { 2059: enqueue({ 2060: value: msg, 2061: mode: 'prompt' 2062: }); 2063: if (i === 0) { 2064: logEvent('tengu_concurrent_onquery_enqueued', {}); 2065: } 2066: }); 2067: return; 2068: } 2069: try { 2070: resetTimingRefs(); 2071: setMessages(oldMessages => [...oldMessages, ...newMessages]); 2072: responseLengthRef.current = 0; 2073: if (feature('TOKEN_BUDGET')) { 2074: const parsedBudget = input ? parseTokenBudget(input) : null; 2075: snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget()); 2076: } 2077: apiMetricsRef.current = []; 2078: setStreamingToolUses([]); 2079: setStreamingText(null); 2080: const latestMessages = messagesRef.current; 2081: if (input) { 2082: await mrOnBeforeQuery(input, latestMessages, newMessages.length); 2083: } 2084: if (onBeforeQueryCallback && input) { 2085: const shouldProceed = await onBeforeQueryCallback(input, latestMessages); 2086: if (!shouldProceed) { 2087: return; 2088: } 2089: } 2090: await onQueryImpl(latestMessages, newMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, effort); 2091: } finally { 2092: if (queryGuard.end(thisGeneration)) { 2093: setLastQueryCompletionTime(Date.now()); 2094: skipIdleCheckRef.current = false; 2095: resetLoadingState(); 2096: await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted); 2097: sendBridgeResultRef.current(); 2098: if ("external" === 'ant' && !abortController.signal.aborted) { 2099: setAppState(prev => { 2100: if (prev.tungstenActiveSession === undefined) return prev; 2101: if (prev.tungstenPanelAutoHidden === true) return prev; 2102: return { 2103: ...prev, 2104: tungstenPanelAutoHidden: true 2105: }; 2106: }); 2107: } 2108: let budgetInfo: { 2109: tokens: number; 2110: limit: number; 2111: nudges: number; 2112: } | undefined; 2113: if (feature('TOKEN_BUDGET')) { 2114: if (getCurrentTurnTokenBudget() !== null && getCurrentTurnTokenBudget()! > 0 && !abortController.signal.aborted) { 2115: budgetInfo = { 2116: tokens: getTurnOutputTokens(), 2117: limit: getCurrentTurnTokenBudget()!, 2118: nudges: getBudgetContinuationCount() 2119: }; 2120: } 2121: snapshotOutputTokensForTurn(null); 2122: } 2123: const turnDurationMs = Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current; 2124: if ((turnDurationMs > 30000 || budgetInfo !== undefined) && !abortController.signal.aborted && !proactiveActive) { 2125: const hasRunningSwarmAgents = getAllInProcessTeammateTasks(store.getState().tasks).some(t => t.status === 'running'); 2126: if (hasRunningSwarmAgents) { 2127: if (swarmStartTimeRef.current === null) { 2128: swarmStartTimeRef.current = loadingStartTimeRef.current; 2129: } 2130: if (budgetInfo) { 2131: swarmBudgetInfoRef.current = budgetInfo; 2132: } 2133: } else { 2134: setMessages(prev => [...prev, createTurnDurationMessage(turnDurationMs, budgetInfo, count(prev, isLoggableMessage))]); 2135: } 2136: } 2137: setAbortController(null); 2138: } 2139: if (abortController.signal.reason === 'user-cancel' && !queryGuard.isActive && inputValueRef.current === '' && getCommandQueueLength() === 0 && !store.getState().viewingAgentTaskId) { 2140: const msgs = messagesRef.current; 2141: const lastUserMsg = msgs.findLast(selectableUserMessagesFilter); 2142: if (lastUserMsg) { 2143: const idx = msgs.lastIndexOf(lastUserMsg); 2144: if (messagesAfterAreOnlySynthetic(msgs, idx)) { 2145: // The submit is being undone — undo its history entry too, 2146: // otherwise Up-arrow shows the restored text twice. 2147: removeLastFromHistory(); 2148: restoreMessageSyncRef.current(lastUserMsg); 2149: } 2150: } 2151: } 2152: } 2153: }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete]); 2154: // Handle initial message (from CLI args or plan mode exit with context clear) 2155: // This effect runs when isLoading becomes false and there's a pending message 2156: const initialMessageRef = useRef(false); 2157: useEffect(() => { 2158: const pending = initialMessage; 2159: if (!pending || isLoading || initialMessageRef.current) return; 2160: initialMessageRef.current = true; 2161: async function processInitialMessage(initialMsg: NonNullable<typeof pending>) { 2162: if (initialMsg.clearContext) { 2163: const oldPlanSlug = initialMsg.message.planContent ? getPlanSlug() : undefined; 2164: const { 2165: clearConversation 2166: } = await import('../commands/clear/conversation.js'); 2167: await clearConversation({ 2168: setMessages, 2169: readFileState: readFileState.current, 2170: discoveredSkillNames: discoveredSkillNamesRef.current, 2171: loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, 2172: getAppState: () => store.getState(), 2173: setAppState, 2174: setConversationId 2175: }); 2176: haikuTitleAttemptedRef.current = false; 2177: setHaikuTitle(undefined); 2178: bashTools.current.clear(); 2179: bashToolsProcessedIdx.current = 0; 2180: if (oldPlanSlug) { 2181: setPlanSlug(getSessionId(), oldPlanSlug); 2182: } 2183: } 2184: const shouldStorePlanForVerification = initialMsg.message.planContent && "external" === 'ant' && isEnvTruthy(undefined); 2185: setAppState(prev => { 2186: let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates(prev.toolPermissionContext, buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts)) : prev.toolPermissionContext; 2187: if (feature('TRANSCRIPT_CLASSIFIER') && initialMsg.mode === 'auto') { 2188: updatedToolPermissionContext = stripDangerousPermissionsForAutoMode({ 2189: ...updatedToolPermissionContext, 2190: mode: 'auto', 2191: prePlanMode: undefined 2192: }); 2193: } 2194: return { 2195: ...prev, 2196: initialMessage: null, 2197: toolPermissionContext: updatedToolPermissionContext, 2198: ...(shouldStorePlanForVerification && { 2199: pendingPlanVerification: { 2200: plan: initialMsg.message.planContent!, 2201: verificationStarted: false, 2202: verificationCompleted: false 2203: } 2204: }) 2205: }; 2206: }); 2207: if (fileHistoryEnabled()) { 2208: void fileHistoryMakeSnapshot((updater: (prev: FileHistoryState) => FileHistoryState) => { 2209: setAppState(prev => ({ 2210: ...prev, 2211: fileHistory: updater(prev.fileHistory) 2212: })); 2213: }, initialMsg.message.uuid); 2214: } 2215: await awaitPendingHooks(); 2216: const content = initialMsg.message.message.content; 2217: if (typeof content === 'string' && !initialMsg.message.planContent) { 2218: void onSubmit(content, { 2219: setCursorOffset: () => {}, 2220: clearBuffer: () => {}, 2221: resetHistory: () => {} 2222: }); 2223: } else { 2224: const newAbortController = createAbortController(); 2225: setAbortController(newAbortController); 2226: void onQuery([initialMsg.message], newAbortController, true, 2227: [], 2228: mainLoopModel); 2229: } 2230: setTimeout(ref => { 2231: ref.current = false; 2232: }, 100, initialMessageRef); 2233: } 2234: void processInitialMessage(pending); 2235: }, [initialMessage, isLoading, setMessages, setAppState, onQuery, mainLoopModel, tools]); 2236: const onSubmit = useCallback(async (input: string, helpers: PromptInputHelpers, speculationAccept?: { 2237: state: ActiveSpeculationState; 2238: speculationSessionTimeSavedMs: number; 2239: setAppState: SetAppState; 2240: }, options?: { 2241: fromKeybinding?: boolean; 2242: }) => { 2243: repinScroll(); 2244: if (feature('PROACTIVE') || feature('KAIROS')) { 2245: proactiveModule?.resumeProactive(); 2246: } 2247: if (!speculationAccept && input.trim().startsWith('/')) { 2248: const trimmedInput = expandPastedTextRefs(input, pastedContents).trim(); 2249: const spaceIndex = trimmedInput.indexOf(' '); 2250: const commandName = spaceIndex === -1 ? trimmedInput.slice(1) : trimmedInput.slice(1, spaceIndex); 2251: const commandArgs = spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim(); 2252: // Find matching command - treat as immediate if: 2253: // 1. Command has `immediate: true`, OR 2254: // 2. Command was triggered via keybinding (fromKeybinding option) 2255: const matchingCommand = commands.find(cmd => isCommandEnabled(cmd) && (cmd.name === commandName || cmd.aliases?.includes(commandName) || getCommandName(cmd) === commandName)); 2256: if (matchingCommand?.name === 'clear' && idleHintShownRef.current) { 2257: logEvent('tengu_idle_return_action', { 2258: action: 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2259: variant: idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2260: idleMinutes: Math.round((Date.now() - lastQueryCompletionTimeRef.current) / 60_000), 2261: messageCount: messagesRef.current.length, 2262: totalInputTokens: getTotalInputTokens() 2263: }); 2264: idleHintShownRef.current = false; 2265: } 2266: const shouldTreatAsImmediate = queryGuard.isActive && (matchingCommand?.immediate || options?.fromKeybinding); 2267: if (matchingCommand && shouldTreatAsImmediate && matchingCommand.type === 'local-jsx') { 2268: if (input.trim() === inputValueRef.current.trim()) { 2269: setInputValue(''); 2270: helpers.setCursorOffset(0); 2271: helpers.clearBuffer(); 2272: setPastedContents({}); 2273: } 2274: const pastedTextRefs = parseReferences(input).filter(r => pastedContents[r.id]?.type === 'text'); 2275: const pastedTextCount = pastedTextRefs.length; 2276: const pastedTextBytes = pastedTextRefs.reduce((sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 0); 2277: logEvent('tengu_paste_text', { 2278: pastedTextCount, 2279: pastedTextBytes 2280: }); 2281: logEvent('tengu_immediate_command_executed', { 2282: commandName: matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2283: fromKeybinding: options?.fromKeybinding ?? false 2284: }); 2285: const executeImmediateCommand = async (): Promise<void> => { 2286: let doneWasCalled = false; 2287: const onDone = (result?: string, doneOptions?: { 2288: display?: CommandResultDisplay; 2289: metaMessages?: string[]; 2290: }): void => { 2291: doneWasCalled = true; 2292: setToolJSX({ 2293: jsx: null, 2294: shouldHidePromptInput: false, 2295: clearLocalJSX: true 2296: }); 2297: const newMessages: MessageType[] = []; 2298: if (result && doneOptions?.display !== 'skip') { 2299: addNotification({ 2300: key: `immediate-${matchingCommand.name}`, 2301: text: result, 2302: priority: 'immediate' 2303: }); 2304: if (!isFullscreenEnvEnabled()) { 2305: newMessages.push(createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), createCommandInputMessage(`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}</${LOCAL_COMMAND_STDOUT_TAG}>`)); 2306: } 2307: } 2308: if (doneOptions?.metaMessages?.length) { 2309: newMessages.push(...doneOptions.metaMessages.map(content => createUserMessage({ 2310: content, 2311: isMeta: true 2312: }))); 2313: } 2314: if (newMessages.length) { 2315: setMessages(prev => [...prev, ...newMessages]); 2316: } 2317: if (stashedPrompt !== undefined) { 2318: setInputValue(stashedPrompt.text); 2319: helpers.setCursorOffset(stashedPrompt.cursorOffset); 2320: setPastedContents(stashedPrompt.pastedContents); 2321: setStashedPrompt(undefined); 2322: } 2323: }; 2324: const context = getToolUseContext(messagesRef.current, [], createAbortController(), mainLoopModel); 2325: const mod = await matchingCommand.load(); 2326: const jsx = await mod.call(onDone, context, commandArgs); 2327: if (jsx && !doneWasCalled) { 2328: setToolJSX({ 2329: jsx, 2330: shouldHidePromptInput: false, 2331: isLocalJSXCommand: true 2332: }); 2333: } 2334: }; 2335: void executeImmediateCommand(); 2336: return; 2337: } 2338: } 2339: if (activeRemote.isRemoteMode && !input.trim()) { 2340: return; 2341: } 2342: { 2343: const willowMode = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off'); 2344: const idleThresholdMin = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75); 2345: const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000); 2346: if (willowMode !== 'off' && !getGlobalConfig().idleReturnDismissed && !skipIdleCheckRef.current && !speculationAccept && !input.trim().startsWith('/') && lastQueryCompletionTimeRef.current > 0 && getTotalInputTokens() >= tokenThreshold) { 2347: const idleMs = Date.now() - lastQueryCompletionTimeRef.current; 2348: const idleMinutes = idleMs / 60_000; 2349: if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') { 2350: setIdleReturnPending({ 2351: input, 2352: idleMinutes 2353: }); 2354: setInputValue(''); 2355: helpers.setCursorOffset(0); 2356: helpers.clearBuffer(); 2357: return; 2358: } 2359: } 2360: } 2361: // Add to history for direct user submissions. 2362: // Queued command processing (executeQueuedInput) doesn't call onSubmit, 2363: if (!options?.fromKeybinding) { 2364: addToHistory({ 2365: display: speculationAccept ? input : prependModeCharacterToInput(input, inputMode), 2366: pastedContents: speculationAccept ? {} : pastedContents 2367: }); 2368: if (inputMode === 'bash') { 2369: prependToShellHistoryCache(input.trim()); 2370: } 2371: } 2372: const isSlashCommand = !speculationAccept && input.trim().startsWith('/'); 2373: const submitsNow = !isLoading || speculationAccept || activeRemote.isRemoteMode; 2374: if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) { 2375: setInputValue(stashedPrompt.text); 2376: helpers.setCursorOffset(stashedPrompt.cursorOffset); 2377: setPastedContents(stashedPrompt.pastedContents); 2378: setStashedPrompt(undefined); 2379: } else if (submitsNow) { 2380: if (!options?.fromKeybinding) { 2381: setInputValue(''); 2382: helpers.setCursorOffset(0); 2383: } 2384: setPastedContents({}); 2385: } 2386: if (submitsNow) { 2387: setInputMode('prompt'); 2388: setIDESelection(undefined); 2389: setSubmitCount(_ => _ + 1); 2390: helpers.clearBuffer(); 2391: tipPickedThisTurnRef.current = false; 2392: if (!isSlashCommand && inputMode === 'prompt' && !speculationAccept && !activeRemote.isRemoteMode) { 2393: setUserInputOnProcessing(input); 2394: resetTimingRefs(); 2395: } 2396: if (feature('COMMIT_ATTRIBUTION')) { 2397: setAppState(prev => ({ 2398: ...prev, 2399: attribution: incrementPromptCount(prev.attribution, snapshot => { 2400: void recordAttributionSnapshot(snapshot).catch(error => { 2401: logForDebugging(`Attribution: Failed to save snapshot: ${error}`); 2402: }); 2403: }) 2404: })); 2405: } 2406: } 2407: if (speculationAccept) { 2408: const { 2409: queryRequired 2410: } = await handleSpeculationAccept(speculationAccept.state, speculationAccept.speculationSessionTimeSavedMs, speculationAccept.setAppState, input, { 2411: setMessages, 2412: readFileState, 2413: cwd: getOriginalCwd() 2414: }); 2415: if (queryRequired) { 2416: const newAbortController = createAbortController(); 2417: setAbortController(newAbortController); 2418: void onQuery([], newAbortController, true, [], mainLoopModel); 2419: } 2420: return; 2421: } 2422: if (activeRemote.isRemoteMode && !(isSlashCommand && commands.find(c => { 2423: const name = input.trim().slice(1).split(/\s/)[0]; 2424: return isCommandEnabled(c) && (c.name === name || c.aliases?.includes(name!) || getCommandName(c) === name); 2425: })?.type === 'local-jsx')) { 2426: const pastedValues = Object.values(pastedContents); 2427: const imageContents = pastedValues.filter(c => c.type === 'image'); 2428: const imagePasteIds = imageContents.length > 0 ? imageContents.map(c => c.id) : undefined; 2429: let messageContent: string | ContentBlockParam[] = input.trim(); 2430: let remoteContent: RemoteMessageContent = input.trim(); 2431: if (pastedValues.length > 0) { 2432: const contentBlocks: ContentBlockParam[] = []; 2433: const remoteBlocks: Array<{ 2434: type: string; 2435: [key: string]: unknown; 2436: }> = []; 2437: const trimmedInput = input.trim(); 2438: if (trimmedInput) { 2439: contentBlocks.push({ 2440: type: 'text', 2441: text: trimmedInput 2442: }); 2443: remoteBlocks.push({ 2444: type: 'text', 2445: text: trimmedInput 2446: }); 2447: } 2448: for (const pasted of pastedValues) { 2449: if (pasted.type === 'image') { 2450: const source = { 2451: type: 'base64' as const, 2452: media_type: (pasted.mediaType ?? 'image/png') as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp', 2453: data: pasted.content 2454: }; 2455: contentBlocks.push({ 2456: type: 'image', 2457: source 2458: }); 2459: remoteBlocks.push({ 2460: type: 'image', 2461: source 2462: }); 2463: } else { 2464: contentBlocks.push({ 2465: type: 'text', 2466: text: pasted.content 2467: }); 2468: remoteBlocks.push({ 2469: type: 'text', 2470: text: pasted.content 2471: }); 2472: } 2473: } 2474: messageContent = contentBlocks; 2475: remoteContent = remoteBlocks; 2476: } 2477: const userMessage = createUserMessage({ 2478: content: messageContent, 2479: imagePasteIds 2480: }); 2481: setMessages(prev => [...prev, userMessage]); 2482: await activeRemote.sendMessage(remoteContent, { 2483: uuid: userMessage.uuid 2484: }); 2485: return; 2486: } 2487: await awaitPendingHooks(); 2488: await handlePromptSubmit({ 2489: input, 2490: helpers, 2491: queryGuard, 2492: isExternalLoading, 2493: mode: inputMode, 2494: commands, 2495: onInputChange: setInputValue, 2496: setPastedContents, 2497: setToolJSX, 2498: getToolUseContext, 2499: messages: messagesRef.current, 2500: mainLoopModel, 2501: pastedContents, 2502: ideSelection, 2503: setUserInputOnProcessing, 2504: setAbortController, 2505: abortController, 2506: onQuery, 2507: setAppState, 2508: querySource: getQuerySourceForREPL(), 2509: onBeforeQuery, 2510: canUseTool, 2511: addNotification, 2512: setMessages, 2513: streamMode: streamModeRef.current, 2514: hasInterruptibleToolInProgress: hasInterruptibleToolInProgressRef.current 2515: }); 2516: if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) { 2517: setInputValue(stashedPrompt.text); 2518: helpers.setCursorOffset(stashedPrompt.cursorOffset); 2519: setPastedContents(stashedPrompt.pastedContents); 2520: setStashedPrompt(undefined); 2521: } 2522: }, [queryGuard, 2523: isLoading, isExternalLoading, inputMode, commands, setInputValue, setInputMode, setPastedContents, setSubmitCount, setIDESelection, setToolJSX, getToolUseContext, 2524: mainLoopModel, pastedContents, ideSelection, setUserInputOnProcessing, setAbortController, addNotification, onQuery, stashedPrompt, setStashedPrompt, setAppState, onBeforeQuery, canUseTool, remoteSession, setMessages, awaitPendingHooks, repinScroll]); 2525: const onAgentSubmit = useCallback(async (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => { 2526: if (isLocalAgentTask(task)) { 2527: appendMessageToLocalAgent(task.id, createUserMessage({ 2528: content: input 2529: }), setAppState); 2530: if (task.status === 'running') { 2531: queuePendingMessage(task.id, input, setAppState); 2532: } else { 2533: void resumeAgentBackground({ 2534: agentId: task.id, 2535: prompt: input, 2536: toolUseContext: getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel), 2537: canUseTool 2538: }).catch(err => { 2539: logForDebugging(`resumeAgentBackground failed: ${errorMessage(err)}`); 2540: addNotification({ 2541: key: `resume-agent-failed-${task.id}`, 2542: jsx: <Text color="error"> 2543: Failed to resume agent: {errorMessage(err)} 2544: </Text>, 2545: priority: 'low' 2546: }); 2547: }); 2548: } 2549: } else { 2550: injectUserMessageToTeammate(task.id, input, setAppState); 2551: } 2552: setInputValue(''); 2553: helpers.setCursorOffset(0); 2554: helpers.clearBuffer(); 2555: }, [setAppState, setInputValue, getToolUseContext, canUseTool, mainLoopModel, addNotification]); 2556: // Handlers for auto-run /issue or /good-claude (defined after onSubmit) 2557: const handleAutoRunIssue = useCallback(() => { 2558: const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue'; 2559: setAutoRunIssueReason(null); 2560: onSubmit(command, { 2561: setCursorOffset: () => {}, 2562: clearBuffer: () => {}, 2563: resetHistory: () => {} 2564: }).catch(err => { 2565: logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`); 2566: }); 2567: }, [onSubmit, autoRunIssueReason]); 2568: const handleCancelAutoRunIssue = useCallback(() => { 2569: setAutoRunIssueReason(null); 2570: }, []); 2571: const handleSurveyRequestFeedback = useCallback(() => { 2572: const command = "external" === 'ant' ? '/issue' : '/feedback'; 2573: onSubmit(command, { 2574: setCursorOffset: () => {}, 2575: clearBuffer: () => {}, 2576: resetHistory: () => {} 2577: }).catch(err => { 2578: logForDebugging(`Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`); 2579: }); 2580: }, [onSubmit]); 2581: const onSubmitRef = useRef(onSubmit); 2582: onSubmitRef.current = onSubmit; 2583: const handleOpenRateLimitOptions = useCallback(() => { 2584: void onSubmitRef.current('/rate-limit-options', { 2585: setCursorOffset: () => {}, 2586: clearBuffer: () => {}, 2587: resetHistory: () => {} 2588: }); 2589: }, []); 2590: const handleExit = useCallback(async () => { 2591: setIsExiting(true); 2592: if (feature('BG_SESSIONS') && isBgSession()) { 2593: spawnSync('tmux', ['detach-client'], { 2594: stdio: 'ignore' 2595: }); 2596: setIsExiting(false); 2597: return; 2598: } 2599: const showWorktree = getCurrentWorktreeSession() !== null; 2600: if (showWorktree) { 2601: setExitFlow(<ExitFlow showWorktree onDone={() => {}} onCancel={() => { 2602: setExitFlow(null); 2603: setIsExiting(false); 2604: }} />); 2605: return; 2606: } 2607: const exitMod = await exit.load(); 2608: const exitFlowResult = await exitMod.call(() => {}); 2609: setExitFlow(exitFlowResult); 2610: if (exitFlowResult === null) { 2611: setIsExiting(false); 2612: } 2613: }, []); 2614: const handleShowMessageSelector = useCallback(() => { 2615: setIsMessageSelectorVisible(prev => !prev); 2616: }, []); 2617: const rewindConversationTo = useCallback((message: UserMessage) => { 2618: const prev = messagesRef.current; 2619: const messageIndex = prev.lastIndexOf(message); 2620: if (messageIndex === -1) return; 2621: logEvent('tengu_conversation_rewind', { 2622: preRewindMessageCount: prev.length, 2623: postRewindMessageCount: messageIndex, 2624: messagesRemoved: prev.length - messageIndex, 2625: rewindToMessageIndex: messageIndex 2626: }); 2627: setMessages(prev.slice(0, messageIndex)); 2628: setConversationId(randomUUID()); 2629: resetMicrocompactState(); 2630: if (feature('CONTEXT_COLLAPSE')) { 2631: ; 2632: (require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js')).resetContextCollapse(); 2633: } 2634: setAppState(prev => ({ 2635: ...prev, 2636: toolPermissionContext: message.permissionMode && prev.toolPermissionContext.mode !== message.permissionMode ? { 2637: ...prev.toolPermissionContext, 2638: mode: message.permissionMode 2639: } : prev.toolPermissionContext, 2640: promptSuggestion: { 2641: text: null, 2642: promptId: null, 2643: shownAt: 0, 2644: acceptedAt: 0, 2645: generationRequestId: null 2646: } 2647: })); 2648: }, [setMessages, setAppState]); 2649: const restoreMessageSync = useCallback((message: UserMessage) => { 2650: rewindConversationTo(message); 2651: const r = textForResubmit(message); 2652: if (r) { 2653: setInputValue(r.text); 2654: setInputMode(r.mode); 2655: } 2656: if (Array.isArray(message.message.content) && message.message.content.some(block => block.type === 'image')) { 2657: const imageBlocks: Array<ImageBlockParam> = message.message.content.filter(block => block.type === 'image'); 2658: if (imageBlocks.length > 0) { 2659: const newPastedContents: Record<number, PastedContent> = {}; 2660: imageBlocks.forEach((block, index) => { 2661: if (block.source.type === 'base64') { 2662: const id = message.imagePasteIds?.[index] ?? index + 1; 2663: newPastedContents[id] = { 2664: id, 2665: type: 'image', 2666: content: block.source.data, 2667: mediaType: block.source.media_type 2668: }; 2669: } 2670: }); 2671: setPastedContents(newPastedContents); 2672: } 2673: } 2674: }, [rewindConversationTo, setInputValue]); 2675: restoreMessageSyncRef.current = restoreMessageSync; 2676: const handleRestoreMessage = useCallback(async (message: UserMessage) => { 2677: setImmediate((restore, message) => restore(message), restoreMessageSync, message); 2678: }, [restoreMessageSync]); 2679: const findRawIndex = (uuid: string) => { 2680: const prefix = uuid.slice(0, 24); 2681: return messages.findIndex(m => m.uuid.slice(0, 24) === prefix); 2682: }; 2683: const messageActionCaps: MessageActionCaps = { 2684: copy: text => 2685: void setClipboard(text).then(raw => { 2686: if (raw) process.stdout.write(raw); 2687: addNotification({ 2688: key: 'selection-copied', 2689: text: 'copied', 2690: color: 'success', 2691: priority: 'immediate', 2692: timeoutMs: 2000 2693: }); 2694: }), 2695: edit: async msg => { 2696: const rawIdx = findRawIndex(msg.uuid); 2697: const raw = rawIdx >= 0 ? messages[rawIdx] : undefined; 2698: if (!raw || !selectableUserMessagesFilter(raw)) return; 2699: const noFileChanges = !(await fileHistoryHasAnyChanges(fileHistory, raw.uuid)); 2700: const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx); 2701: if (noFileChanges && onlySynthetic) { 2702: onCancel(); 2703: void handleRestoreMessage(raw); 2704: } else { 2705: setMessageSelectorPreselect(raw); 2706: setIsMessageSelectorVisible(true); 2707: } 2708: } 2709: }; 2710: const { 2711: enter: enterMessageActions, 2712: handlers: messageActionHandlers 2713: } = useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps); 2714: async function onInit() { 2715: void reverify(); 2716: const memoryFiles = await getMemoryFiles(); 2717: if (memoryFiles.length > 0) { 2718: const fileList = memoryFiles.map(f => ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`).join('\n'); 2719: logForDebugging(`Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`); 2720: } else { 2721: logForDebugging('No CLAUDE.md/rules files found'); 2722: } 2723: for (const file of memoryFiles) { 2724: readFileState.current.set(file.path, { 2725: content: file.contentDiffersFromDisk ? file.rawContent ?? file.content : file.content, 2726: timestamp: Date.now(), 2727: offset: undefined, 2728: limit: undefined, 2729: isPartialView: file.contentDiffersFromDisk 2730: }); 2731: } 2732: } 2733: useCostSummary(useFpsMetrics()); 2734: useLogMessages(messages, messages.length === initialMessages?.length); 2735: const { 2736: sendBridgeResult 2737: } = useReplBridge(messages, setMessages, abortControllerRef, commands, mainLoopModel); 2738: sendBridgeResultRef.current = sendBridgeResult; 2739: useAfterFirstRender(); 2740: const hasCountedQueueUseRef = useRef(false); 2741: useEffect(() => { 2742: if (queuedCommands.length < 1) { 2743: hasCountedQueueUseRef.current = false; 2744: return; 2745: } 2746: if (hasCountedQueueUseRef.current) return; 2747: hasCountedQueueUseRef.current = true; 2748: saveGlobalConfig(current => ({ 2749: ...current, 2750: promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1 2751: })); 2752: }, [queuedCommands.length]); 2753: const executeQueuedInput = useCallback(async (queuedCommands: QueuedCommand[]) => { 2754: await handlePromptSubmit({ 2755: helpers: { 2756: setCursorOffset: () => {}, 2757: clearBuffer: () => {}, 2758: resetHistory: () => {} 2759: }, 2760: queryGuard, 2761: commands, 2762: onInputChange: () => {}, 2763: setPastedContents: () => {}, 2764: setToolJSX, 2765: getToolUseContext, 2766: messages, 2767: mainLoopModel, 2768: ideSelection, 2769: setUserInputOnProcessing, 2770: setAbortController, 2771: onQuery, 2772: setAppState, 2773: querySource: getQuerySourceForREPL(), 2774: onBeforeQuery, 2775: canUseTool, 2776: addNotification, 2777: setMessages, 2778: queuedCommands 2779: }); 2780: }, [queryGuard, commands, setToolJSX, getToolUseContext, messages, mainLoopModel, ideSelection, setUserInputOnProcessing, canUseTool, setAbortController, onQuery, addNotification, setAppState, onBeforeQuery]); 2781: useQueueProcessor({ 2782: executeQueuedInput, 2783: hasActiveLocalJsxUI: isShowingLocalJSXCommand, 2784: queryGuard 2785: }); 2786: useEffect(() => { 2787: activityManager.recordUserActivity(); 2788: updateLastInteractionTime(true); 2789: }, [inputValue, submitCount]); 2790: useEffect(() => { 2791: if (submitCount === 1) { 2792: startBackgroundHousekeeping(); 2793: } 2794: }, [submitCount]); 2795: useEffect(() => { 2796: if (isLoading) return; 2797: if (submitCount === 0) return; 2798: if (lastQueryCompletionTime === 0) return; 2799: const timer = setTimeout((lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal) => { 2800: const lastUserInteraction = getLastInteractionTime(); 2801: if (lastUserInteraction > lastQueryCompletionTime) { 2802: return; 2803: } 2804: const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime; 2805: if (!isLoading && !toolJSX && 2806: focusedInputDialogRef.current === undefined && idleTimeSinceResponse >= getGlobalConfig().messageIdleNotifThresholdMs) { 2807: void sendNotification({ 2808: message: 'Claude is waiting for your input', 2809: notificationType: 'idle_prompt' 2810: }, terminal); 2811: } 2812: }, getGlobalConfig().messageIdleNotifThresholdMs, lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal); 2813: return () => clearTimeout(timer); 2814: }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]); 2815: useEffect(() => { 2816: if (lastQueryCompletionTime === 0) return; 2817: if (isLoading) return; 2818: const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off'); 2819: if (willowMode !== 'hint' && willowMode !== 'hint_v2') return; 2820: if (getGlobalConfig().idleReturnDismissed) return; 2821: const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000); 2822: if (getTotalInputTokens() < tokenThreshold) return; 2823: const idleThresholdMs = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000; 2824: const elapsed = Date.now() - lastQueryCompletionTime; 2825: const remaining = idleThresholdMs - elapsed; 2826: const timer = setTimeout((lqct, addNotif, msgsRef, mode, hintRef) => { 2827: if (msgsRef.current.length === 0) return; 2828: const totalTokens = getTotalInputTokens(); 2829: const formattedTokens = formatTokens(totalTokens); 2830: const idleMinutes = (Date.now() - lqct) / 60_000; 2831: addNotif({ 2832: key: 'idle-return-hint', 2833: jsx: mode === 'hint_v2' ? <> 2834: <Text dimColor>new task? </Text> 2835: <Text color="suggestion">/clear</Text> 2836: <Text dimColor> to save </Text> 2837: <Text color="suggestion">{formattedTokens} tokens</Text> 2838: </> : <Text color="warning"> 2839: new task? /clear to save {formattedTokens} tokens 2840: </Text>, 2841: priority: 'medium', 2842: timeoutMs: 0x7fffffff 2843: }); 2844: hintRef.current = mode; 2845: logEvent('tengu_idle_return_action', { 2846: action: 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2847: variant: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2848: idleMinutes: Math.round(idleMinutes), 2849: messageCount: msgsRef.current.length, 2850: totalInputTokens: totalTokens 2851: }); 2852: }, Math.max(0, remaining), lastQueryCompletionTime, addNotification, messagesRef, willowMode, idleHintShownRef); 2853: return () => { 2854: clearTimeout(timer); 2855: removeNotification('idle-return-hint'); 2856: idleHintShownRef.current = false; 2857: }; 2858: }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]); 2859: const handleIncomingPrompt = useCallback((content: string, options?: { 2860: isMeta?: boolean; 2861: }): boolean => { 2862: if (queryGuard.isActive) return false; 2863: if (getCommandQueue().some(cmd => cmd.mode === 'prompt' || cmd.mode === 'bash')) { 2864: return false; 2865: } 2866: const newAbortController = createAbortController(); 2867: setAbortController(newAbortController); 2868: const userMessage = createUserMessage({ 2869: content, 2870: isMeta: options?.isMeta ? true : undefined 2871: }); 2872: void onQuery([userMessage], newAbortController, true, [], mainLoopModel); 2873: return true; 2874: }, [onQuery, mainLoopModel, store]); 2875: const voice = feature('VOICE_MODE') ? 2876: useVoiceIntegration({ 2877: setInputValueRaw, 2878: inputValueRef, 2879: insertTextRef 2880: }) : { 2881: stripTrailing: () => 0, 2882: handleKeyEvent: () => {}, 2883: resetAnchor: () => {}, 2884: interimRange: null 2885: }; 2886: useInboxPoller({ 2887: enabled: isAgentSwarmsEnabled(), 2888: isLoading, 2889: focusedInputDialog, 2890: onSubmitMessage: handleIncomingPrompt 2891: }); 2892: useMailboxBridge({ 2893: isLoading, 2894: onSubmitMessage: handleIncomingPrompt 2895: }); 2896: if (feature('AGENT_TRIGGERS')) { 2897: const assistantMode = store.getState().kairosEnabled; 2898: useScheduledTasks!({ 2899: isLoading, 2900: assistantMode, 2901: setMessages 2902: }); 2903: } 2904: if ("external" === 'ant') { 2905: useTaskListWatcher({ 2906: taskListId, 2907: isLoading, 2908: onSubmitTask: handleIncomingPrompt 2909: }); 2910: useProactive?.({ 2911: isLoading: isLoading || initialMessage !== null, 2912: queuedCommandsLength: queuedCommands.length, 2913: hasActiveLocalJsxUI: isShowingLocalJSXCommand, 2914: isInPlanMode: toolPermissionContext.mode === 'plan', 2915: onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { 2916: isMeta: true 2917: }), 2918: onQueueTick: (prompt: string) => enqueue({ 2919: mode: 'prompt', 2920: value: prompt, 2921: isMeta: true 2922: }) 2923: }); 2924: } 2925: useEffect(() => { 2926: if (queuedCommands.some(cmd => cmd.priority === 'now')) { 2927: abortControllerRef.current?.abort('interrupt'); 2928: } 2929: }, [queuedCommands]); 2930: useEffect(() => { 2931: void onInit(); 2932: return () => { 2933: void diagnosticTracker.shutdown(); 2934: }; 2935: }, []); 2936: const { 2937: internal_eventEmitter 2938: } = useStdin(); 2939: const [remountKey, setRemountKey] = useState(0); 2940: useEffect(() => { 2941: const handleSuspend = () => { 2942: process.stdout.write(`\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`); 2943: }; 2944: const handleResume = () => { 2945: setRemountKey(prev => prev + 1); 2946: }; 2947: internal_eventEmitter?.on('suspend', handleSuspend); 2948: internal_eventEmitter?.on('resume', handleResume); 2949: return () => { 2950: internal_eventEmitter?.off('suspend', handleSuspend); 2951: internal_eventEmitter?.off('resume', handleResume); 2952: }; 2953: }, [internal_eventEmitter]); 2954: const stopHookSpinnerSuffix = useMemo(() => { 2955: if (!isLoading) return null; 2956: const progressMsgs = messages.filter((m): m is ProgressMessage<HookProgress> => m.type === 'progress' && m.data.type === 'hook_progress' && (m.data.hookEvent === 'Stop' || m.data.hookEvent === 'SubagentStop')); 2957: if (progressMsgs.length === 0) return null; 2958: const currentToolUseID = progressMsgs.at(-1)?.toolUseID; 2959: if (!currentToolUseID) return null; 2960: const hasSummaryForCurrentExecution = messages.some(m => m.type === 'system' && m.subtype === 'stop_hook_summary' && m.toolUseID === currentToolUseID); 2961: if (hasSummaryForCurrentExecution) return null; 2962: const currentHooks = progressMsgs.filter(p => p.toolUseID === currentToolUseID); 2963: const total = currentHooks.length; 2964: const completedCount = count(messages, m => { 2965: if (m.type !== 'attachment') return false; 2966: const attachment = m.attachment; 2967: return 'hookEvent' in attachment && (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') && 'toolUseID' in attachment && attachment.toolUseID === currentToolUseID; 2968: }); 2969: const customMessage = currentHooks.find(p => p.data.statusMessage)?.data.statusMessage; 2970: if (customMessage) { 2971: return total === 1 ? `${customMessage}…` : `${customMessage}… ${completedCount}/${total}`; 2972: } 2973: const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop'; 2974: if ("external" === 'ant') { 2975: const cmd = currentHooks[completedCount]?.data.command; 2976: const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : ''; 2977: return total === 1 ? `running ${hookType} hook${label}` : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`; 2978: } 2979: return total === 1 ? `running ${hookType} hook` : `running stop hooks… ${completedCount}/${total}`; 2980: }, [messages, isLoading]); 2981: // Callback to capture frozen state when entering transcript mode 2982: const handleEnterTranscript = useCallback(() => { 2983: setFrozenTranscriptState({ 2984: messagesLength: messages.length, 2985: streamingToolUsesLength: streamingToolUses.length 2986: }); 2987: }, [messages.length, streamingToolUses.length]); 2988: // Callback to clear frozen state when exiting transcript mode 2989: const handleExitTranscript = useCallback(() => { 2990: setFrozenTranscriptState(null); 2991: }, []); 2992: // Props for GlobalKeybindingHandlers component (rendered inside KeybindingSetup) 2993: const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll; 2994: // Transcript search state. Hooks must be unconditional so they live here 2995: // (not inside the `if (screen === 'transcript')` branch below); isActive 2996: // gates the useInput. Query persists across bar open/close so n/N keep 2997: // working after Enter dismisses the bar (less semantics). 2998: const jumpRef = useRef<JumpHandle | null>(null); 2999: const [searchOpen, setSearchOpen] = useState(false); 3000: const [searchQuery, setSearchQuery] = useState(''); 3001: const [searchCount, setSearchCount] = useState(0); 3002: const [searchCurrent, setSearchCurrent] = useState(0); 3003: const onSearchMatchesChange = useCallback((count: number, current: number) => { 3004: setSearchCount(count); 3005: setSearchCurrent(current); 3006: }, []); 3007: useInput((input, key, event) => { 3008: if (key.ctrl || key.meta) return; 3009: // No Esc handling here — less has no navigating mode. Search state 3010: // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit 3011: // (ungated). Highlights clear on exit via the screen-change effect. 3012: if (input === '/') { 3013: // Capture scrollTop NOW — typing is a preview, 0-matches snaps 3014: // back here. Synchronous ref write, fires before the bar's 3015: // mount-effect calls setSearchQuery. 3016: jumpRef.current?.setAnchor(); 3017: setSearchOpen(true); 3018: event.stopImmediatePropagation(); 3019: return; 3020: } 3021: // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch 3022: // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each 3023: // repeat is a step (n isn't idempotent like g). 3024: const c = input[0]; 3025: if ((c === 'n' || c === 'N') && input === c.repeat(input.length) && searchCount > 0) { 3026: const fn = c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch; 3027: if (fn) for (let i = 0; i < input.length; i++) fn(); 3028: event.stopImmediatePropagation(); 3029: } 3030: }, 3031: // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ 3032: // kills it, so !dumpMode — after [ there's nothing to jump in. 3033: { 3034: isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode 3035: }); 3036: const { 3037: setQuery: setHighlight, 3038: scanElement, 3039: setPositions 3040: } = useSearchHighlight(); 3041: // Resize → abort search. Positions are (msg, query, WIDTH)-keyed — 3042: // cached positions are stale after a width change (new layout, new 3043: // wrapping). Clearing searchQuery triggers VML's setSearchQuery('') 3044: // which clears positionsCache + setPositions(null). Bar closes. 3045: // User hits / again → fresh everything. 3046: const transcriptCols = useTerminalSize().columns; 3047: const prevColsRef = React.useRef(transcriptCols); 3048: React.useEffect(() => { 3049: if (prevColsRef.current !== transcriptCols) { 3050: prevColsRef.current = transcriptCols; 3051: if (searchQuery || searchOpen) { 3052: setSearchOpen(false); 3053: setSearchQuery(''); 3054: setSearchCount(0); 3055: setSearchCurrent(0); 3056: jumpRef.current?.disarmSearch(); 3057: setHighlight(''); 3058: } 3059: } 3060: }, [transcriptCols, searchQuery, searchOpen, setHighlight]); 3061: // Transcript escape hatches. Bare letters in modal context (no prompt 3062: // competing for input) — same class as g/G/j/k in ScrollKeybindingHandler. 3063: useInput((input, key, event) => { 3064: if (key.ctrl || key.meta) return; 3065: if (input === 'q') { 3066: // less: q quits the pager. ctrl+o toggles; q is the lineage exit. 3067: handleExitTranscript(); 3068: event.stopImmediatePropagation(); 3069: return; 3070: } 3071: if (input === '[' && !dumpMode) { 3072: // Force dump-to-scrollback. Also expand + uncap — no point dumping 3073: // a subset. Terminal/tmux cmd-F can now find anything. Guard here 3074: // (not in isActive) so v still works post-[ — dump-mode footer at 3075: // ~4898 wires editorStatus, confirming v is meant to stay live. 3076: setDumpMode(true); 3077: setShowAllInTranscript(true); 3078: event.stopImmediatePropagation(); 3079: } else if (input === 'v') { 3080: // less-style: v opens the file in $VISUAL/$EDITOR. Render the full 3081: // transcript (same path /export uses), write to tmp, hand off. 3082: // openFileInExternalEditor handles alt-screen suspend/resume for 3083: // terminal editors; GUI editors spawn detached. 3084: event.stopImmediatePropagation(); 3085: // Drop double-taps: the render is async and a second press before it 3086: // completes would run a second parallel render (double memory, two 3087: // tempfiles, two editor spawns). editorGenRef only guards 3088: // transcript-exit staleness, not same-session concurrency. 3089: if (editorRenderingRef.current) return; 3090: editorRenderingRef.current = true; 3091: // Capture generation + make a staleness-aware setter. Each write 3092: // checks gen (transcript exit bumps it → late writes from the 3093: // async render go silent). 3094: const gen = editorGenRef.current; 3095: const setStatus = (s: string): void => { 3096: if (gen !== editorGenRef.current) return; 3097: clearTimeout(editorTimerRef.current); 3098: setEditorStatus(s); 3099: }; 3100: setStatus(`rendering ${deferredMessages.length} messages…`); 3101: void (async () => { 3102: try { 3103: // Width = terminal minus vim's line-number gutter (4 digits + 3104: // space + slack). Floor at 80. PassThrough has no .columns so 3105: // without this Ink defaults to 80. Trailing-space strip: right- 3106: // aligned timestamps still leave a flexbox spacer run at EOL. 3107: // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep 3108: const w = Math.max(80, (process.stdout.columns ?? 80) - 6); 3109: const raw = await renderMessagesToPlainText(deferredMessages, tools, w); 3110: const text = raw.replace(/[ \t]+$/gm, ''); 3111: const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`); 3112: await writeFile(path, text); 3113: const opened = openFileInExternalEditor(path); 3114: setStatus(opened ? `opening ${path}` : `wrote ${path} · no $VISUAL/$EDITOR set`); 3115: } catch (e) { 3116: setStatus(`render failed: ${e instanceof Error ? e.message : String(e)}`); 3117: } 3118: editorRenderingRef.current = false; 3119: if (gen !== editorGenRef.current) return; 3120: editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus); 3121: })(); 3122: } 3123: }, 3124: // !searchOpen: typing 'v' or '[' in the search bar is search input, not 3125: { 3126: isActive: screen === 'transcript' && virtualScrollActive && !searchOpen 3127: }); 3128: const inTranscript = screen === 'transcript' && virtualScrollActive; 3129: useEffect(() => { 3130: if (!inTranscript) { 3131: setSearchQuery(''); 3132: setSearchCount(0); 3133: setSearchCurrent(0); 3134: setSearchOpen(false); 3135: editorGenRef.current++; 3136: clearTimeout(editorTimerRef.current); 3137: setDumpMode(false); 3138: setEditorStatus(''); 3139: } 3140: }, [inTranscript]); 3141: useEffect(() => { 3142: setHighlight(inTranscript ? searchQuery : ''); 3143: // Clear the position-based CURRENT (yellow) overlay too. setHighlight 3144: // only clears the scan-based inverse. Without this, the yellow box 3145: // persists at its last screen coords after ctrl-c exits transcript. 3146: if (!inTranscript) setPositions(null); 3147: }, [inTranscript, searchQuery, setHighlight, setPositions]); 3148: const globalKeybindingProps = { 3149: screen, 3150: setScreen, 3151: showAllInTranscript, 3152: setShowAllInTranscript, 3153: messageCount: messages.length, 3154: onEnterTranscript: handleEnterTranscript, 3155: onExitTranscript: handleExitTranscript, 3156: virtualScrollActive, 3157: // Bar-open is a mode (owns keystrokes — j/k type, Esc cancels). 3158: // Navigating (query set, bar closed) is NOT — Esc exits transcript, 3159: // same as less q with highlights still visible. useSearchInput 3160: // doesn't stopPropagation, so without this gate transcript:exit 3161: searchBarOpen: searchOpen 3162: }; 3163: const transcriptMessages = frozenTranscriptState ? deferredMessages.slice(0, frozenTranscriptState.messagesLength) : deferredMessages; 3164: const transcriptStreamingToolUses = frozenTranscriptState ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength) : streamingToolUses; 3165: useBackgroundTaskNavigation({ 3166: onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true) 3167: }); 3168: useTeammateViewAutoExit(); 3169: if (screen === 'transcript') { 3170: const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined; 3171: const transcriptMessagesElement = <Messages messages={transcriptMessages} tools={tools} commands={commands} verbose={true} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={inProgressToolUseIDs} isMessageSelectorVisible={false} conversationId={conversationId} screen={screen} agentDefinitions={agentDefinitions} streamingToolUses={transcriptStreamingToolUses} showAllInTranscript={showAllInTranscript} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} hidePastThinking={true} streamingThinking={streamingThinking} scrollRef={transcriptScrollRef} jumpRef={jumpRef} onSearchMatchesChange={onSearchMatchesChange} scanElement={scanElement} setPositions={setPositions} disableRenderCap={dumpMode} />; 3172: const transcriptToolJSX = toolJSX && <Box flexDirection="column" width="100%"> 3173: {toolJSX.jsx} 3174: </Box>; 3175: const transcriptReturn = <KeybindingSetup> 3176: <AnimatedTerminalTitle isAnimating={titleIsAnimating} title={terminalTitle} disabled={titleDisabled} noPrefix={showStatusInTerminalTab} /> 3177: <GlobalKeybindingHandlers {...globalKeybindingProps} /> 3178: {feature('VOICE_MODE') ? <VoiceKeybindingHandler voiceHandleKeyEvent={voice.handleKeyEvent} stripTrailing={voice.stripTrailing} resetAnchor={voice.resetAnchor} isActive={!toolJSX?.isLocalJSXCommand} /> : null} 3179: <CommandKeybindingHandlers onSubmit={onSubmit} isActive={!toolJSX?.isLocalJSXCommand} /> 3180: {transcriptScrollRef ? 3181: <ScrollKeybindingHandler scrollRef={scrollRef} 3182: isActive={focusedInputDialog !== 'ultraplan-choice'} 3183: isModal={!searchOpen} 3184: onScroll={() => jumpRef.current?.disarmSearch()} /> : null} 3185: <CancelRequestHandler {...cancelRequestProps} /> 3186: {transcriptScrollRef ? <FullscreenLayout scrollRef={scrollRef} scrollable={<> 3187: {transcriptMessagesElement} 3188: {transcriptToolJSX} 3189: <SandboxViolationExpandedView /> 3190: </>} bottom={searchOpen ? <TranscriptSearchBar jumpRef={jumpRef} 3191: initialQuery="" count={searchCount} current={searchCurrent} onClose={q => { 3192: // Enter — commit. 0-match guard: junk query shouldn't 3193: // persist (badge hidden, n/N dead anyway). 3194: setSearchQuery(searchCount > 0 ? q : ''); 3195: setSearchOpen(false); 3196: // onCancel path: bar unmounts before its useEffect([query]) 3197: // can fire with ''. Without this, searchCount stays stale 3198: // (n guard at :4956 passes) and VML's matches[] too 3199: // (nextMatch walks the old array). Phantom nav, no 3200: // highlight. onExit (Enter, q non-empty) still commits. 3201: if (!q) { 3202: setSearchCount(0); 3203: setSearchCurrent(0); 3204: jumpRef.current?.setSearchQuery(''); 3205: } 3206: }} onCancel={() => { 3207: // Esc/ctrl+c/ctrl+g — undo. Bar's effect last fired 3208: // with whatever was typed. searchQuery (REPL state) 3209: // is unchanged since / (onClose = commit, didn't run). 3210: // Two VML calls: '' restores anchor (0-match else- 3211: // branch), then searchQuery re-scans from anchor's 3212: // nearest. Both synchronous — one React batch. 3213: // setHighlight explicit: REPL's sync-effect dep is 3214: // searchQuery (unchanged), wouldn't re-fire. 3215: setSearchOpen(false); 3216: jumpRef.current?.setSearchQuery(''); 3217: jumpRef.current?.setSearchQuery(searchQuery); 3218: setHighlight(searchQuery); 3219: }} setHighlight={setHighlight} /> : <TranscriptModeFooter showAllInTranscript={showAllInTranscript} virtualScroll={true} status={editorStatus || undefined} searchBadge={searchQuery && searchCount > 0 ? { 3220: current: searchCurrent, 3221: count: searchCount 3222: } : undefined} />} /> : <> 3223: {transcriptMessagesElement} 3224: {transcriptToolJSX} 3225: <SandboxViolationExpandedView /> 3226: <TranscriptModeFooter showAllInTranscript={showAllInTranscript} virtualScroll={false} suppressShowAll={dumpMode} status={editorStatus || undefined} /> 3227: </>} 3228: </KeybindingSetup>; 3229: // The virtual-scroll branch (FullscreenLayout above) needs 3230: // <AlternateScreen>'s <Box height={rows}> constraint — without it, 3231: // ScrollBox's flexGrow has no ceiling, viewport = content height, 3232: // scrollTop pins at 0, and Ink's screen buffer sizes to the full 3233: // spacer (200×5k+ rows on long sessions). Same root type + props as 3234: // normal mode's wrap below so React reconciles and the alt buffer 3235: // stays entered across toggle. The 30-cap dump branch stays 3236: // unwrapped — it wants native terminal scrollback. 3237: if (transcriptScrollRef) { 3238: return <AlternateScreen mouseTracking={isMouseTrackingEnabled()}> 3239: {transcriptReturn} 3240: </AlternateScreen>; 3241: } 3242: return transcriptReturn; 3243: } 3244: // Get viewed agent task (inlined from selectors for explicit data flow). 3245: // viewedAgentTask: teammate OR local_agent — drives the boolean checks 3246: // below. viewedTeammateTask: teammate-only narrowed, for teammate-specific 3247: // field access (inProgressToolUseIDs). 3248: const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; 3249: const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined; 3250: const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined); 3251: // Bypass useDeferredValue when streaming text is showing so Messages renders 3252: // the final message in the same frame streaming text clears. Also bypass when 3253: // not loading — deferredMessages only matters during streaming (keeps input 3254: // responsive); after the turn ends, showing messages immediately prevents a 3255: // jitter gap where the spinner is gone but the answer hasn't appeared yet. 3256: // Only reducedMotion users keep the deferred path during loading. 3257: const usesSyncMessages = showStreamingText || !isLoading; 3258: // When viewing an agent, never fall through to leader — empty until 3259: // bootstrap/stream fills. Closes the see-leader-type-agent footgun. 3260: const displayedMessages = viewedAgentTask ? viewedAgentTask.messages ?? [] : usesSyncMessages ? messages : deferredMessages; 3261: // Show the placeholder until the real user message appears in 3262: // displayedMessages. userInputOnProcessing stays set for the whole turn 3263: // (cleared in resetLoadingState); this length check hides it once 3264: // displayedMessages grows past the baseline captured at submit time. 3265: // Covers both gaps: before setMessages is called (processUserInput), and 3266: // while deferredMessages lags behind messages. Suppressed when viewing an 3267: // agent — displayedMessages is a different array there, and onAgentSubmit 3268: // doesn't use the placeholder anyway. 3269: const placeholderText = userInputOnProcessing && !viewedAgentTask && displayedMessages.length <= userInputBaselineRef.current ? userInputOnProcessing : undefined; 3270: const toolPermissionOverlay = focusedInputDialog === 'tool-permission' ? <PermissionRequest key={toolUseConfirmQueue[0]?.toolUseID} onDone={() => setToolUseConfirmQueue(([_, ...tail]) => tail)} onReject={handleQueuedCommandOnCancel} toolUseConfirm={toolUseConfirmQueue[0]!} toolUseContext={getToolUseContext(messages, messages, abortController ?? createAbortController(), mainLoopModel)} verbose={verbose} workerBadge={toolUseConfirmQueue[0]?.workerBadge} setStickyFooter={isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined} /> : null; 3271: // Narrow terminals: companion collapses to a one-liner that REPL stacks 3272: // on its own row (above input in fullscreen, below in scrollback) instead 3273: // of row-beside. Wide terminals keep the row layout with sprite on the right. 3274: const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE; 3275: // Hide the sprite when PromptInput early-returns BackgroundTasksDialog. 3276: // The sprite sits as a row sibling of PromptInput, so the dialog's Pane 3277: // divider draws at useTerminalSize() width but only gets terminalWidth - 3278: // spriteWidth — divider stops short and dialog text wraps early. Don't 3279: // check footerSelection: pill FOCUS (arrow-down to tasks pill) must keep 3280: // the sprite visible so arrow-right can navigate to it. 3281: const companionVisible = !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog; 3282: // In fullscreen, ALL local-jsx slash commands float in the modal slot — 3283: // FullscreenLayout wraps them in an absolute-positioned bottom-anchored 3284: // pane (▔ divider, ModalContext). Pane/Dialog inside detect the context 3285: // and skip their own top-level frame. Non-fullscreen keeps the inline 3286: // render paths below. Commands that used to route through bottom 3287: // (immediate: /model, /mcp, /btw, ...) and scrollable (non-immediate: 3288: // /config, /theme, /diff, ...) both go here now. 3289: const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true; 3290: const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null; 3291: // <AlternateScreen> at the root: everything below is inside its 3292: // <Box height={rows}>. Handlers/contexts are zero-height so ScrollBox's 3293: // flexGrow in FullscreenLayout resolves against this Box. The transcript 3294: // early return above wraps its virtual-scroll branch the same way; only 3295: // the 30-cap dump branch stays unwrapped for native terminal scrollback. 3296: const mainReturn = <KeybindingSetup> 3297: <AnimatedTerminalTitle isAnimating={titleIsAnimating} title={terminalTitle} disabled={titleDisabled} noPrefix={showStatusInTerminalTab} /> 3298: <GlobalKeybindingHandlers {...globalKeybindingProps} /> 3299: {feature('VOICE_MODE') ? <VoiceKeybindingHandler voiceHandleKeyEvent={voice.handleKeyEvent} stripTrailing={voice.stripTrailing} resetAnchor={voice.resetAnchor} isActive={!toolJSX?.isLocalJSXCommand} /> : null} 3300: <CommandKeybindingHandlers onSubmit={onSubmit} isActive={!toolJSX?.isLocalJSXCommand} /> 3301: {/* ScrollKeybindingHandler must mount before CancelRequestHandler so 3302: ctrl+c-with-selection copies instead of cancelling the active task. 3303: Its raw useInput handler only stops propagation when a selection 3304: exists — without one, ctrl+c falls through to CancelRequestHandler. 3305: PgUp/PgDn/wheel always scroll the transcript behind the modal — 3306: the modal's inner ScrollBox is not keyboard-driven. onScroll 3307: stays suppressed while a modal is showing so scroll doesn't 3308: stamp divider/pill state. */} 3309: <ScrollKeybindingHandler scrollRef={scrollRef} isActive={isFullscreenEnvEnabled() && (centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission')} onScroll={centeredModal || toolPermissionOverlay || viewedAgentTask ? undefined : composedOnScroll} /> 3310: {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? <MessageActionsKeybindings handlers={messageActionHandlers} isActive={cursor !== null} /> : null} 3311: <CancelRequestHandler {...cancelRequestProps} /> 3312: <MCPConnectionManager key={remountKey} dynamicMcpConfig={dynamicMcpConfig} isStrictMcpConfig={strictMcpConfig}> 3313: <FullscreenLayout scrollRef={scrollRef} overlay={toolPermissionOverlay} bottomFloat={feature('BUDDY') && companionVisible && !companionNarrow ? <CompanionFloatingBubble /> : undefined} modal={centeredModal} modalScrollRef={modalScrollRef} dividerYRef={dividerYRef} hidePill={!!viewedAgentTask} hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { 3314: setCursor(null); 3315: jumpToNew(scrollRef.current); 3316: }} scrollable={<> 3317: <TeammateViewHeader /> 3318: <Messages messages={displayedMessages} tools={tools} commands={commands} verbose={verbose} toolJSX={toolJSX} toolUseConfirmQueue={toolUseConfirmQueue} inProgressToolUseIDs={viewedTeammateTask ? viewedTeammateTask.inProgressToolUseIDs ?? new Set() : inProgressToolUseIDs} isMessageSelectorVisible={isMessageSelectorVisible} conversationId={conversationId} screen={screen} streamingToolUses={streamingToolUses} showAllInTranscript={showAllInTranscript} agentDefinitions={agentDefinitions} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} streamingText={isLoading && !viewedAgentTask ? visibleStreamingText : null} isBriefOnly={viewedAgentTask ? false : isBriefOnly} unseenDivider={viewedAgentTask ? undefined : unseenDivider} scrollRef={isFullscreenEnvEnabled() ? scrollRef : undefined} trackStickyPrompt={isFullscreenEnvEnabled() ? true : undefined} cursor={cursor} setCursor={setCursor} cursorNavRef={cursorNavRef} /> 3319: <AwsAuthStatusBox /> 3320: {/* Hide the processing placeholder while a modal is showing — 3321: it would sit at the last visible transcript row right above 3322: the ▔ divider, showing "❯ /config" as redundant clutter 3323: (the modal IS the /config UI). Outside modals it stays so 3324: the user sees their input echoed while Claude processes. */} 3325: {!disabled && placeholderText && !centeredModal && <UserTextMessage param={{ 3326: text: placeholderText, 3327: type: 'text' 3328: }} addMargin={true} verbose={verbose} />} 3329: {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && <Box flexDirection="column" width="100%"> 3330: {toolJSX.jsx} 3331: </Box>} 3332: {"external" === 'ant' && <TungstenLiveMonitor />} 3333: {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && <WebBrowserPanelModule.WebBrowserPanel /> : null} 3334: <Box flexGrow={1} /> 3335: {showSpinner && <SpinnerWithVerb mode={streamMode} spinnerTip={spinnerTip} responseLengthRef={responseLengthRef} apiMetricsRef={apiMetricsRef} overrideMessage={spinnerMessage} spinnerSuffix={stopHookSpinnerSuffix} verbose={verbose} loadingStartTimeRef={loadingStartTimeRef} totalPausedMsRef={totalPausedMsRef} pauseStartTimeRef={pauseStartTimeRef} overrideColor={spinnerColor} overrideShimmerColor={spinnerShimmerColor} hasActiveTools={inProgressToolUseIDs.size > 0} leaderIsIdle={!isLoading} />} 3336: {!showSpinner && !isLoading && !userInputOnProcessing && !hasRunningTeammates && isBriefOnly && !viewedAgentTask && <BriefIdleStatus />} 3337: {isFullscreenEnvEnabled() && <PromptInputQueuedCommands />} 3338: </>} bottom={<Box flexDirection={feature('BUDDY') && companionNarrow ? 'column' : 'row'} width="100%" alignItems={feature('BUDDY') && companionNarrow ? undefined : 'flex-end'}> 3339: {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? <CompanionSprite /> : null} 3340: <Box flexDirection="column" flexGrow={1}> 3341: {permissionStickyFooter} 3342: { 3343: } 3344: {toolJSX?.isLocalJSXCommand && toolJSX.isImmediate && !toolJsxCentered && <Box flexDirection="column" width="100%"> 3345: {toolJSX.jsx} 3346: </Box>} 3347: {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 && <Box width="100%" flexDirection="column"> 3348: <TaskListV2 tasks={tasksV2} isStandalone={true} /> 3349: </Box>} 3350: {focusedInputDialog === 'sandbox-permission' && <SandboxPermissionRequest key={sandboxPermissionRequestQueue[0]!.hostPattern.host} hostPattern={sandboxPermissionRequestQueue[0]!.hostPattern} onUserResponse={(response: { 3351: allow: boolean; 3352: persistToSettings: boolean; 3353: }) => { 3354: const { 3355: allow, 3356: persistToSettings 3357: } = response; 3358: const currentRequest = sandboxPermissionRequestQueue[0]; 3359: if (!currentRequest) return; 3360: const approvedHost = currentRequest.hostPattern.host; 3361: if (persistToSettings) { 3362: const update = { 3363: type: 'addRules' as const, 3364: rules: [{ 3365: toolName: WEB_FETCH_TOOL_NAME, 3366: ruleContent: `domain:${approvedHost}` 3367: }], 3368: behavior: (allow ? 'allow' : 'deny') as 'allow' | 'deny', 3369: destination: 'localSettings' as const 3370: }; 3371: setAppState(prev => ({ 3372: ...prev, 3373: toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update) 3374: })); 3375: persistPermissionUpdate(update); 3376: SandboxManager.refreshConfig(); 3377: } 3378: setSandboxPermissionRequestQueue(queue => { 3379: queue.filter(item => item.hostPattern.host === approvedHost).forEach(item => item.resolvePromise(allow)); 3380: return queue.filter(item => item.hostPattern.host !== approvedHost); 3381: }); 3382: const cleanups = sandboxBridgeCleanupRef.current.get(approvedHost); 3383: if (cleanups) { 3384: for (const fn of cleanups) { 3385: fn(); 3386: } 3387: sandboxBridgeCleanupRef.current.delete(approvedHost); 3388: } 3389: }} />} 3390: {focusedInputDialog === 'prompt' && <PromptDialog key={promptQueue[0]!.request.prompt} title={promptQueue[0]!.title} toolInputSummary={promptQueue[0]!.toolInputSummary} request={promptQueue[0]!.request} onRespond={selectedKey => { 3391: const item = promptQueue[0]; 3392: if (!item) return; 3393: item.resolve({ 3394: prompt_response: item.request.prompt, 3395: selected: selectedKey 3396: }); 3397: setPromptQueue(([, ...tail]) => tail); 3398: }} onAbort={() => { 3399: const item = promptQueue[0]; 3400: if (!item) return; 3401: item.reject(new Error('Prompt cancelled by user')); 3402: setPromptQueue(([, ...tail]) => tail); 3403: }} />} 3404: {} 3405: {pendingWorkerRequest && <WorkerPendingPermission toolName={pendingWorkerRequest.toolName} description={pendingWorkerRequest.description} />} 3406: {} 3407: {pendingSandboxRequest && <WorkerPendingPermission toolName="Network Access" description={`Waiting for leader to approve network access to ${pendingSandboxRequest.host}`} />} 3408: {} 3409: {focusedInputDialog === 'worker-sandbox-permission' && <SandboxPermissionRequest key={workerSandboxPermissions.queue[0]!.requestId} hostPattern={{ 3410: host: workerSandboxPermissions.queue[0]!.host, 3411: port: undefined 3412: } as NetworkHostPattern} onUserResponse={(response: { 3413: allow: boolean; 3414: persistToSettings: boolean; 3415: }) => { 3416: const { 3417: allow, 3418: persistToSettings 3419: } = response; 3420: const currentRequest = workerSandboxPermissions.queue[0]; 3421: if (!currentRequest) return; 3422: const approvedHost = currentRequest.host; 3423: void sendSandboxPermissionResponseViaMailbox(currentRequest.workerName, currentRequest.requestId, approvedHost, allow, teamContext?.teamName); 3424: if (persistToSettings && allow) { 3425: const update = { 3426: type: 'addRules' as const, 3427: rules: [{ 3428: toolName: WEB_FETCH_TOOL_NAME, 3429: ruleContent: `domain:${approvedHost}` 3430: }], 3431: behavior: 'allow' as const, 3432: destination: 'localSettings' as const 3433: }; 3434: setAppState(prev => ({ 3435: ...prev, 3436: toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update) 3437: })); 3438: persistPermissionUpdate(update); 3439: SandboxManager.refreshConfig(); 3440: } 3441: setAppState(prev => ({ 3442: ...prev, 3443: workerSandboxPermissions: { 3444: ...prev.workerSandboxPermissions, 3445: queue: prev.workerSandboxPermissions.queue.slice(1) 3446: } 3447: })); 3448: }} />} 3449: {focusedInputDialog === 'elicitation' && <ElicitationDialog key={elicitation.queue[0]!.serverName + ':' + String(elicitation.queue[0]!.requestId)} event={elicitation.queue[0]!} onResponse={(action, content) => { 3450: const currentRequest = elicitation.queue[0]; 3451: if (!currentRequest) return; 3452: currentRequest.respond({ 3453: action, 3454: content 3455: }); 3456: const isUrlAccept = currentRequest.params.mode === 'url' && action === 'accept'; 3457: if (!isUrlAccept) { 3458: setAppState(prev => ({ 3459: ...prev, 3460: elicitation: { 3461: queue: prev.elicitation.queue.slice(1) 3462: } 3463: })); 3464: } 3465: }} onWaitingDismiss={action => { 3466: const currentRequest = elicitation.queue[0]; 3467: setAppState(prev => ({ 3468: ...prev, 3469: elicitation: { 3470: queue: prev.elicitation.queue.slice(1) 3471: } 3472: })); 3473: currentRequest?.onWaitingDismiss?.(action); 3474: }} />} 3475: {focusedInputDialog === 'cost' && <CostThresholdDialog onDone={() => { 3476: setShowCostDialog(false); 3477: setHaveShownCostDialog(true); 3478: saveGlobalConfig(current => ({ 3479: ...current, 3480: hasAcknowledgedCostThreshold: true 3481: })); 3482: logEvent('tengu_cost_threshold_acknowledged', {}); 3483: }} />} 3484: {focusedInputDialog === 'idle-return' && idleReturnPending && <IdleReturnDialog idleMinutes={idleReturnPending.idleMinutes} totalInputTokens={getTotalInputTokens()} onDone={async action => { 3485: const pending = idleReturnPending; 3486: setIdleReturnPending(null); 3487: logEvent('tengu_idle_return_action', { 3488: action: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 3489: idleMinutes: Math.round(pending.idleMinutes), 3490: messageCount: messagesRef.current.length, 3491: totalInputTokens: getTotalInputTokens() 3492: }); 3493: if (action === 'dismiss') { 3494: setInputValue(pending.input); 3495: return; 3496: } 3497: if (action === 'never') { 3498: saveGlobalConfig(current => { 3499: if (current.idleReturnDismissed) return current; 3500: return { 3501: ...current, 3502: idleReturnDismissed: true 3503: }; 3504: }); 3505: } 3506: if (action === 'clear') { 3507: const { 3508: clearConversation 3509: } = await import('../commands/clear/conversation.js'); 3510: await clearConversation({ 3511: setMessages, 3512: readFileState: readFileState.current, 3513: discoveredSkillNames: discoveredSkillNamesRef.current, 3514: loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, 3515: getAppState: () => store.getState(), 3516: setAppState, 3517: setConversationId 3518: }); 3519: haikuTitleAttemptedRef.current = false; 3520: setHaikuTitle(undefined); 3521: bashTools.current.clear(); 3522: bashToolsProcessedIdx.current = 0; 3523: } 3524: skipIdleCheckRef.current = true; 3525: void onSubmitRef.current(pending.input, { 3526: setCursorOffset: () => {}, 3527: clearBuffer: () => {}, 3528: resetHistory: () => {} 3529: }); 3530: }} />} 3531: {focusedInputDialog === 'ide-onboarding' && <IdeOnboardingDialog onDone={() => setShowIdeOnboarding(false)} installationStatus={ideInstallationStatus} />} 3532: {"external" === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && <AntModelSwitchCallout onDone={(selection: string, modelAlias?: string) => { 3533: setShowModelSwitchCallout(false); 3534: if (selection === 'switch' && modelAlias) { 3535: setAppState(prev => ({ 3536: ...prev, 3537: mainLoopModel: modelAlias, 3538: mainLoopModelForSession: null 3539: })); 3540: } 3541: }} />} 3542: {"external" === 'ant' && focusedInputDialog === 'undercover-callout' && UndercoverAutoCallout && <UndercoverAutoCallout onDone={() => setShowUndercoverCallout(false)} />} 3543: {focusedInputDialog === 'effort-callout' && <EffortCallout model={mainLoopModel} onDone={selection => { 3544: setShowEffortCallout(false); 3545: if (selection !== 'dismiss') { 3546: setAppState(prev => ({ 3547: ...prev, 3548: effortValue: selection 3549: })); 3550: } 3551: }} />} 3552: {focusedInputDialog === 'remote-callout' && <RemoteCallout onDone={selection => { 3553: setAppState(prev => { 3554: if (!prev.showRemoteCallout) return prev; 3555: return { 3556: ...prev, 3557: showRemoteCallout: false, 3558: ...(selection === 'enable' && { 3559: replBridgeEnabled: true, 3560: replBridgeExplicit: true, 3561: replBridgeOutboundOnly: false 3562: }) 3563: }; 3564: }); 3565: }} />} 3566: {exitFlow} 3567: {focusedInputDialog === 'plugin-hint' && hintRecommendation && <PluginHintMenu pluginName={hintRecommendation.pluginName} pluginDescription={hintRecommendation.pluginDescription} marketplaceName={hintRecommendation.marketplaceName} sourceCommand={hintRecommendation.sourceCommand} onResponse={handleHintResponse} />} 3568: {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && <LspRecommendationMenu pluginName={lspRecommendation.pluginName} pluginDescription={lspRecommendation.pluginDescription} fileExtension={lspRecommendation.fileExtension} onResponse={handleLspResponse} />} 3569: {focusedInputDialog === 'desktop-upsell' && <DesktopUpsellStartup onDone={() => setShowDesktopUpsellStartup(false)} />} 3570: {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-choice' && ultraplanPendingChoice && <UltraplanChoiceDialog plan={ultraplanPendingChoice.plan} sessionId={ultraplanPendingChoice.sessionId} taskId={ultraplanPendingChoice.taskId} setMessages={setMessages} readFileState={readFileState.current} getAppState={() => store.getState()} setConversationId={setConversationId} /> : null} 3571: {feature('ULTRAPLAN') ? focusedInputDialog === 'ultraplan-launch' && ultraplanLaunchPending && <UltraplanLaunchDialog onChoice={(choice, opts) => { 3572: const blurb = ultraplanLaunchPending.blurb; 3573: setAppState(prev => prev.ultraplanLaunchPending ? { 3574: ...prev, 3575: ultraplanLaunchPending: undefined 3576: } : prev); 3577: if (choice === 'cancel') return; 3578: setMessages(prev => [...prev, createCommandInputMessage(formatCommandInputTags('ultraplan', blurb))]); 3579: const appendStdout = (msg: string) => setMessages(prev => [...prev, createCommandInputMessage(`<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}</${LOCAL_COMMAND_STDOUT_TAG}>`)]); 3580: const appendWhenIdle = (msg: string) => { 3581: if (!queryGuard.isActive) { 3582: appendStdout(msg); 3583: return; 3584: } 3585: const unsub = queryGuard.subscribe(() => { 3586: if (queryGuard.isActive) return; 3587: unsub(); 3588: if (!store.getState().ultraplanSessionUrl) return; 3589: appendStdout(msg); 3590: }); 3591: }; 3592: void launchUltraplan({ 3593: blurb, 3594: getAppState: () => store.getState(), 3595: setAppState, 3596: signal: createAbortController().signal, 3597: disconnectedBridge: opts?.disconnectedBridge, 3598: onSessionReady: appendWhenIdle 3599: }).then(appendStdout).catch(logError); 3600: }} /> : null} 3601: {mrRender()} 3602: {!toolJSX?.shouldHidePromptInput && !focusedInputDialog && !isExiting && !disabled && !cursor && <> 3603: {autoRunIssueReason && <AutoRunIssueNotification onRun={handleAutoRunIssue} onCancel={handleCancelAutoRunIssue} reason={getAutoRunIssueReasonText(autoRunIssueReason)} />} 3604: {postCompactSurvey.state !== 'closed' ? <FeedbackSurvey state={postCompactSurvey.state} lastResponse={postCompactSurvey.lastResponse} handleSelect={postCompactSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={handleSurveyRequestFeedback} /> : memorySurvey.state !== 'closed' ? <FeedbackSurvey state={memorySurvey.state} lastResponse={memorySurvey.lastResponse} handleSelect={memorySurvey.handleSelect} handleTranscriptSelect={memorySurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={handleSurveyRequestFeedback} message="How well did Claude use its memory? (optional)" /> : <FeedbackSurvey state={feedbackSurvey.state} lastResponse={feedbackSurvey.lastResponse} handleSelect={feedbackSurvey.handleSelect} handleTranscriptSelect={feedbackSurvey.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} onRequestFeedback={didAutoRunIssueRef.current ? undefined : handleSurveyRequestFeedback} />} 3605: {} 3606: {frustrationDetection.state !== 'closed' && <FeedbackSurvey state={frustrationDetection.state} lastResponse={null} handleSelect={() => {}} handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} inputValue={inputValue} setInputValue={setInputValue} />} 3607: {} 3608: {"external" === 'ant' && skillImprovementSurvey.suggestion && <SkillImprovementSurvey isOpen={skillImprovementSurvey.isOpen} skillName={skillImprovementSurvey.suggestion.skillName} updates={skillImprovementSurvey.suggestion.updates} handleSelect={skillImprovementSurvey.handleSelect} inputValue={inputValue} setInputValue={setInputValue} />} 3609: {showIssueFlagBanner && <IssueFlagBanner />} 3610: {} 3611: <PromptInput debug={debug} ideSelection={ideSelection} hasSuppressedDialogs={!!hasSuppressedDialogs} isLocalJSXCommandActive={isShowingLocalJSXCommand} getToolUseContext={getToolUseContext} toolPermissionContext={toolPermissionContext} setToolPermissionContext={setToolPermissionContext} apiKeyStatus={apiKeyStatus} commands={commands} agents={agentDefinitions.activeAgents} isLoading={isLoading} onExit={handleExit} verbose={verbose} messages={messages} onAutoUpdaterResult={setAutoUpdaterResult} autoUpdaterResult={autoUpdaterResult} input={inputValue} onInputChange={setInputValue} mode={inputMode} onModeChange={setInputMode} stashedPrompt={stashedPrompt} setStashedPrompt={setStashedPrompt} submitCount={submitCount} onShowMessageSelector={handleShowMessageSelector} onMessageActionsEnter={ 3612: feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? enterMessageActions : undefined} mcpClients={mcpClients} pastedContents={pastedContents} setPastedContents={setPastedContents} vimMode={vimMode} setVimMode={setVimMode} showBashesDialog={showBashesDialog} setShowBashesDialog={setShowBashesDialog} onSubmit={onSubmit} onAgentSubmit={onAgentSubmit} isSearchingHistory={isSearchingHistory} setIsSearchingHistory={setIsSearchingHistory} helpOpen={isHelpOpen} setHelpOpen={setIsHelpOpen} insertTextRef={feature('VOICE_MODE') ? insertTextRef : undefined} voiceInterimRange={voice.interimRange} /> 3613: <SessionBackgroundHint onBackgroundSession={handleBackgroundSession} isLoading={isLoading} /> 3614: </>} 3615: {cursor && 3616: <MessageActionsBar cursor={cursor} />} 3617: {focusedInputDialog === 'message-selector' && <MessageSelector messages={messages} preselectedMessage={messageSelectorPreselect} onPreRestore={onCancel} onRestoreCode={async (message: UserMessage) => { 3618: await fileHistoryRewind((updater: (prev: FileHistoryState) => FileHistoryState) => { 3619: setAppState(prev => ({ 3620: ...prev, 3621: fileHistory: updater(prev.fileHistory) 3622: })); 3623: }, message.uuid); 3624: }} onSummarize={async (message: UserMessage, feedback?: string, direction: PartialCompactDirection = 'from') => { 3625: const compactMessages = getMessagesAfterCompactBoundary(messages); 3626: const messageIndex = compactMessages.indexOf(message); 3627: if (messageIndex === -1) { 3628: setMessages(prev => [...prev, createSystemMessage('That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', 'warning')]); 3629: return; 3630: } 3631: const newAbortController = createAbortController(); 3632: const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel); 3633: const appState = context.getAppState(); 3634: const defaultSysPrompt = await getSystemPrompt(context.options.tools, context.options.mainLoopModel, Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()), context.options.mcpClients); 3635: const systemPrompt = buildEffectiveSystemPrompt({ 3636: mainThreadAgentDefinition: undefined, 3637: toolUseContext: context, 3638: customSystemPrompt: context.options.customSystemPrompt, 3639: defaultSystemPrompt: defaultSysPrompt, 3640: appendSystemPrompt: context.options.appendSystemPrompt 3641: }); 3642: const [userContext, systemContext] = await Promise.all([getUserContext(), getSystemContext()]); 3643: const result = await partialCompactConversation(compactMessages, messageIndex, context, { 3644: systemPrompt, 3645: userContext, 3646: systemContext, 3647: toolUseContext: context, 3648: forkContextMessages: compactMessages 3649: }, feedback, direction); 3650: const kept = result.messagesToKeep ?? []; 3651: const ordered = direction === 'up_to' ? [...result.summaryMessages, ...kept] : [...kept, ...result.summaryMessages]; 3652: const postCompact = [result.boundaryMarker, ...ordered, ...result.attachments, ...result.hookResults]; 3653: if (isFullscreenEnvEnabled() && direction === 'from') { 3654: setMessages(old => { 3655: const rawIdx = old.findIndex(m => m.uuid === message.uuid); 3656: return [...old.slice(0, rawIdx === -1 ? 0 : rawIdx), ...postCompact]; 3657: }); 3658: } else { 3659: setMessages(postCompact); 3660: } 3661: if (feature('PROACTIVE') || feature('KAIROS')) { 3662: proactiveModule?.setContextBlocked(false); 3663: } 3664: setConversationId(randomUUID()); 3665: runPostCompactCleanup(context.options.querySource); 3666: if (direction === 'from') { 3667: const r = textForResubmit(message); 3668: if (r) { 3669: setInputValue(r.text); 3670: setInputMode(r.mode); 3671: } 3672: } 3673: const historyShortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); 3674: addNotification({ 3675: key: 'summarize-ctrl-o-hint', 3676: text: `Conversation summarized (${historyShortcut} for history)`, 3677: priority: 'medium', 3678: timeoutMs: 8000 3679: }); 3680: }} onRestoreMessage={handleRestoreMessage} onClose={() => { 3681: setIsMessageSelectorVisible(false); 3682: setMessageSelectorPreselect(undefined); 3683: }} />} 3684: {"external" === 'ant' && <DevBar />} 3685: </Box> 3686: {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? <CompanionSprite /> : null} 3687: </Box>} /> 3688: </MCPConnectionManager> 3689: </KeybindingSetup>; 3690: if (isFullscreenEnvEnabled()) { 3691: return <AlternateScreen mouseTracking={isMouseTrackingEnabled()}> 3692: {mainReturn} 3693: </AlternateScreen>; 3694: } 3695: return mainReturn; 3696: }

File: src/screens/ResumeConversation.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import { feature } from 'bun:bundle'; 3: import { dirname } from 'path'; 4: import React from 'react'; 5: import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; 6: import { getOriginalCwd, switchSession } from '../bootstrap/state.js'; 7: import type { Command } from '../commands.js'; 8: import { LogSelector } from '../components/LogSelector.js'; 9: import { Spinner } from '../components/Spinner.js'; 10: import { restoreCostStateForSession } from '../cost-tracker.js'; 11: import { setClipboard } from '../ink/termio/osc.js'; 12: import { Box, Text } from '../ink.js'; 13: import { useKeybinding } from '../keybindings/useKeybinding.js'; 14: import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../services/analytics/index.js'; 15: import type { MCPServerConnection, ScopedMcpServerConfig } from '../services/mcp/types.js'; 16: import { useAppState, useSetAppState } from '../state/AppState.js'; 17: import type { Tool } from '../Tool.js'; 18: import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; 19: import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; 20: import { asSessionId } from '../types/ids.js'; 21: import type { LogOption } from '../types/logs.js'; 22: import type { Message } from '../types/message.js'; 23: import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'; 24: import { renameRecordingForSession } from '../utils/asciicast.js'; 25: import { updateSessionName } from '../utils/concurrentSessions.js'; 26: import { loadConversationForResume } from '../utils/conversationRecovery.js'; 27: import { checkCrossProjectResume } from '../utils/crossProjectResume.js'; 28: import type { FileHistorySnapshot } from '../utils/fileHistory.js'; 29: import { logError } from '../utils/log.js'; 30: import { createSystemMessage } from '../utils/messages.js'; 31: import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; 32: import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult } from '../utils/sessionStorage.js'; 33: import type { ThinkingConfig } from '../utils/thinking.js'; 34: import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; 35: import { REPL } from './REPL.js'; 36: function parsePrIdentifier(value: string): number | null { 37: const directNumber = parseInt(value, 10); 38: if (!isNaN(directNumber) && directNumber > 0) { 39: return directNumber; 40: } 41: const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); 42: if (urlMatch?.[1]) { 43: return parseInt(urlMatch[1], 10); 44: } 45: return null; 46: } 47: type Props = { 48: commands: Command[]; 49: worktreePaths: string[]; 50: initialTools: Tool[]; 51: mcpClients?: MCPServerConnection[]; 52: dynamicMcpConfig?: Record<string, ScopedMcpServerConfig>; 53: debug: boolean; 54: mainThreadAgentDefinition?: AgentDefinition; 55: autoConnectIdeFlag?: boolean; 56: strictMcpConfig?: boolean; 57: systemPrompt?: string; 58: appendSystemPrompt?: string; 59: initialSearchQuery?: string; 60: disableSlashCommands?: boolean; 61: forkSession?: boolean; 62: taskListId?: string; 63: filterByPr?: boolean | number | string; 64: thinkingConfig: ThinkingConfig; 65: onTurnComplete?: (messages: Message[]) => void | Promise<void>; 66: }; 67: export function ResumeConversation({ 68: commands, 69: worktreePaths, 70: initialTools, 71: mcpClients, 72: dynamicMcpConfig, 73: debug, 74: mainThreadAgentDefinition, 75: autoConnectIdeFlag, 76: strictMcpConfig = false, 77: systemPrompt, 78: appendSystemPrompt, 79: initialSearchQuery, 80: disableSlashCommands = false, 81: forkSession, 82: taskListId, 83: filterByPr, 84: thinkingConfig, 85: onTurnComplete 86: }: Props): React.ReactNode { 87: const { 88: rows 89: } = useTerminalSize(); 90: const agentDefinitions = useAppState(s => s.agentDefinitions); 91: const setAppState = useSetAppState(); 92: const [logs, setLogs] = React.useState<LogOption[]>([]); 93: const [loading, setLoading] = React.useState(true); 94: const [resuming, setResuming] = React.useState(false); 95: const [showAllProjects, setShowAllProjects] = React.useState(false); 96: const [resumeData, setResumeData] = React.useState<{ 97: messages: Message[]; 98: fileHistorySnapshots?: FileHistorySnapshot[]; 99: contentReplacements?: ContentReplacementRecord[]; 100: agentName?: string; 101: agentColor?: AgentColorName; 102: mainThreadAgentDefinition?: AgentDefinition; 103: } | null>(null); 104: const [crossProjectCommand, setCrossProjectCommand] = React.useState<string | null>(null); 105: const sessionLogResultRef = React.useRef<SessionLogResult | null>(null); 106: const logCountRef = React.useRef(0); 107: const filteredLogs = React.useMemo(() => { 108: let result = logs.filter(l => !l.isSidechain); 109: if (filterByPr !== undefined) { 110: if (filterByPr === true) { 111: result = result.filter(l_0 => l_0.prNumber !== undefined); 112: } else if (typeof filterByPr === 'number') { 113: result = result.filter(l_1 => l_1.prNumber === filterByPr); 114: } else if (typeof filterByPr === 'string') { 115: const prNumber = parsePrIdentifier(filterByPr); 116: if (prNumber !== null) { 117: result = result.filter(l_2 => l_2.prNumber === prNumber); 118: } 119: } 120: } 121: return result; 122: }, [logs, filterByPr]); 123: const isResumeWithRenameEnabled = isCustomTitleEnabled(); 124: React.useEffect(() => { 125: loadSameRepoMessageLogsProgressive(worktreePaths).then(result_0 => { 126: sessionLogResultRef.current = result_0; 127: logCountRef.current = result_0.logs.length; 128: setLogs(result_0.logs); 129: setLoading(false); 130: }).catch(error => { 131: logError(error); 132: setLoading(false); 133: }); 134: }, [worktreePaths]); 135: const loadMoreLogs = React.useCallback((count: number) => { 136: const ref = sessionLogResultRef.current; 137: if (!ref || ref.nextIndex >= ref.allStatLogs.length) return; 138: void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result_1 => { 139: ref.nextIndex = result_1.nextIndex; 140: if (result_1.logs.length > 0) { 141: const offset = logCountRef.current; 142: result_1.logs.forEach((log, i) => { 143: log.value = offset + i; 144: }); 145: setLogs(prev => prev.concat(result_1.logs)); 146: logCountRef.current += result_1.logs.length; 147: } else if (ref.nextIndex < ref.allStatLogs.length) { 148: loadMoreLogs(count); 149: } 150: }); 151: }, []); 152: const loadLogs = React.useCallback((allProjects: boolean) => { 153: setLoading(true); 154: const promise = allProjects ? loadAllProjectsMessageLogsProgressive() : loadSameRepoMessageLogsProgressive(worktreePaths); 155: promise.then(result_2 => { 156: sessionLogResultRef.current = result_2; 157: logCountRef.current = result_2.logs.length; 158: setLogs(result_2.logs); 159: }).catch(error_0 => { 160: logError(error_0); 161: }).finally(() => { 162: setLoading(false); 163: }); 164: }, [worktreePaths]); 165: const handleToggleAllProjects = React.useCallback(() => { 166: const newValue = !showAllProjects; 167: setShowAllProjects(newValue); 168: loadLogs(newValue); 169: }, [showAllProjects, loadLogs]); 170: function onCancel() { 171: process.exit(1); 172: } 173: async function onSelect(log_0: LogOption) { 174: setResuming(true); 175: const resumeStart = performance.now(); 176: const crossProjectCheck = checkCrossProjectResume(log_0, showAllProjects, worktreePaths); 177: if (crossProjectCheck.isCrossProject) { 178: if (!crossProjectCheck.isSameRepoWorktree) { 179: const raw = await setClipboard(crossProjectCheck.command); 180: if (raw) process.stdout.write(raw); 181: setCrossProjectCommand(crossProjectCheck.command); 182: return; 183: } 184: } 185: try { 186: const result_3 = await loadConversationForResume(log_0, undefined); 187: if (!result_3) { 188: throw new Error('Failed to load conversation'); 189: } 190: if (feature('COORDINATOR_MODE')) { 191: const coordinatorModule = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); 192: const warning = coordinatorModule.matchSessionMode(result_3.mode); 193: if (warning) { 194: const { 195: getAgentDefinitionsWithOverrides, 196: getActiveAgentsFromList 197: } = require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js'); 198: getAgentDefinitionsWithOverrides.cache.clear?.(); 199: const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); 200: setAppState(prev_0 => ({ 201: ...prev_0, 202: agentDefinitions: { 203: ...freshAgentDefs, 204: allAgents: freshAgentDefs.allAgents, 205: activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents) 206: } 207: })); 208: result_3.messages.push(createSystemMessage(warning, 'warning')); 209: } 210: } 211: if (result_3.sessionId && !forkSession) { 212: switchSession(asSessionId(result_3.sessionId), log_0.fullPath ? dirname(log_0.fullPath) : null); 213: await renameRecordingForSession(); 214: await resetSessionFilePointer(); 215: restoreCostStateForSession(result_3.sessionId); 216: } else if (forkSession && result_3.contentReplacements?.length) { 217: await recordContentReplacement(result_3.contentReplacements); 218: } 219: const { 220: agentDefinition: resolvedAgentDef 221: } = restoreAgentFromSession(result_3.agentSetting, mainThreadAgentDefinition, agentDefinitions); 222: setAppState(prev_1 => ({ 223: ...prev_1, 224: agent: resolvedAgentDef?.agentType 225: })); 226: if (feature('COORDINATOR_MODE')) { 227: const { 228: saveMode 229: } = require('../utils/sessionStorage.js'); 230: const { 231: isCoordinatorMode 232: } = require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); 233: saveMode(isCoordinatorMode() ? 'coordinator' : 'normal'); 234: } 235: const standaloneAgentContext = computeStandaloneAgentContext(result_3.agentName, result_3.agentColor); 236: if (standaloneAgentContext) { 237: setAppState(prev_2 => ({ 238: ...prev_2, 239: standaloneAgentContext 240: })); 241: } 242: void updateSessionName(result_3.agentName); 243: restoreSessionMetadata(forkSession ? { 244: ...result_3, 245: worktreeSession: undefined 246: } : result_3); 247: if (!forkSession) { 248: restoreWorktreeForResume(result_3.worktreeSession); 249: if (result_3.sessionId) { 250: adoptResumedSessionFile(); 251: } 252: } 253: if (feature('CONTEXT_COLLAPSE')) { 254: ; 255: (require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js')).restoreFromEntries(result_3.contextCollapseCommits ?? [], result_3.contextCollapseSnapshot); 256: } 257: logEvent('tengu_session_resumed', { 258: entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 259: success: true, 260: resume_duration_ms: Math.round(performance.now() - resumeStart) 261: }); 262: setLogs([]); 263: setResumeData({ 264: messages: result_3.messages, 265: fileHistorySnapshots: result_3.fileHistorySnapshots, 266: contentReplacements: result_3.contentReplacements, 267: agentName: result_3.agentName, 268: agentColor: (result_3.agentColor === 'default' ? undefined : result_3.agentColor) as AgentColorName | undefined, 269: mainThreadAgentDefinition: resolvedAgentDef 270: }); 271: } catch (e) { 272: logEvent('tengu_session_resumed', { 273: entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 274: success: false 275: }); 276: logError(e as Error); 277: throw e; 278: } 279: } 280: if (crossProjectCommand) { 281: return <CrossProjectMessage command={crossProjectCommand} />; 282: } 283: if (resumeData) { 284: return <REPL debug={debug} commands={commands} initialTools={initialTools} initialMessages={resumeData.messages} initialFileHistorySnapshots={resumeData.fileHistorySnapshots} initialContentReplacements={resumeData.contentReplacements} initialAgentName={resumeData.agentName} initialAgentColor={resumeData.agentColor} mcpClients={mcpClients} dynamicMcpConfig={dynamicMcpConfig} strictMcpConfig={strictMcpConfig} systemPrompt={systemPrompt} appendSystemPrompt={appendSystemPrompt} mainThreadAgentDefinition={resumeData.mainThreadAgentDefinition} autoConnectIdeFlag={autoConnectIdeFlag} disableSlashCommands={disableSlashCommands} taskListId={taskListId} thinkingConfig={thinkingConfig} onTurnComplete={onTurnComplete} />; 285: } 286: if (loading) { 287: return <Box> 288: <Spinner /> 289: <Text> Loading conversations…</Text> 290: </Box>; 291: } 292: if (resuming) { 293: return <Box> 294: <Spinner /> 295: <Text> Resuming conversation…</Text> 296: </Box>; 297: } 298: if (filteredLogs.length === 0) { 299: return <NoConversationsMessage />; 300: } 301: return <LogSelector logs={filteredLogs} maxHeight={rows} onCancel={onCancel} onSelect={onSelect} onLogsChanged={isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} />; 302: } 303: function NoConversationsMessage() { 304: const $ = _c(2); 305: let t0; 306: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 307: t0 = { 308: context: "Global" 309: }; 310: $[0] = t0; 311: } else { 312: t0 = $[0]; 313: } 314: useKeybinding("app:interrupt", _temp, t0); 315: let t1; 316: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 317: t1 = <Box flexDirection="column"><Text>No conversations found to resume.</Text><Text dimColor={true}>Press Ctrl+C to exit and start a new conversation.</Text></Box>; 318: $[1] = t1; 319: } else { 320: t1 = $[1]; 321: } 322: return t1; 323: } 324: function _temp() { 325: process.exit(1); 326: } 327: function CrossProjectMessage(t0) { 328: const $ = _c(8); 329: const { 330: command 331: } = t0; 332: let t1; 333: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 334: t1 = []; 335: $[0] = t1; 336: } else { 337: t1 = $[0]; 338: } 339: React.useEffect(_temp3, t1); 340: let t2; 341: if ($[1] === Symbol.for("react.memo_cache_sentinel")) { 342: t2 = <Text>This conversation is from a different directory.</Text>; 343: $[1] = t2; 344: } else { 345: t2 = $[1]; 346: } 347: let t3; 348: if ($[2] === Symbol.for("react.memo_cache_sentinel")) { 349: t3 = <Text>To resume, run:</Text>; 350: $[2] = t3; 351: } else { 352: t3 = $[2]; 353: } 354: let t4; 355: if ($[3] !== command) { 356: t4 = <Box flexDirection="column">{t3}<Text> {command}</Text></Box>; 357: $[3] = command; 358: $[4] = t4; 359: } else { 360: t4 = $[4]; 361: } 362: let t5; 363: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 364: t5 = <Text dimColor={true}>(Command copied to clipboard)</Text>; 365: $[5] = t5; 366: } else { 367: t5 = $[5]; 368: } 369: let t6; 370: if ($[6] !== t4) { 371: t6 = <Box flexDirection="column" gap={1}>{t2}{t4}{t5}</Box>; 372: $[6] = t4; 373: $[7] = t6; 374: } else { 375: t6 = $[7]; 376: } 377: return t6; 378: } 379: function _temp3() { 380: const timeout = setTimeout(_temp2, 100); 381: return () => clearTimeout(timeout); 382: } 383: function _temp2() { 384: process.exit(0); 385: }

File: src/server/createDirectConnectSession.ts

typescript 1: import { errorMessage } from '../utils/errors.js' 2: import { jsonStringify } from '../utils/slowOperations.js' 3: import type { DirectConnectConfig } from './directConnectManager.js' 4: import { connectResponseSchema } from './types.js' 5: export class DirectConnectError extends Error { 6: constructor(message: string) { 7: super(message) 8: this.name = 'DirectConnectError' 9: } 10: } 11: export async function createDirectConnectSession({ 12: serverUrl, 13: authToken, 14: cwd, 15: dangerouslySkipPermissions, 16: }: { 17: serverUrl: string 18: authToken?: string 19: cwd: string 20: dangerouslySkipPermissions?: boolean 21: }): Promise<{ 22: config: DirectConnectConfig 23: workDir?: string 24: }> { 25: const headers: Record<string, string> = { 26: 'content-type': 'application/json', 27: } 28: if (authToken) { 29: headers['authorization'] = `Bearer ${authToken}` 30: } 31: let resp: Response 32: try { 33: resp = await fetch(`${serverUrl}/sessions`, { 34: method: 'POST', 35: headers, 36: body: jsonStringify({ 37: cwd, 38: ...(dangerouslySkipPermissions && { 39: dangerously_skip_permissions: true, 40: }), 41: }), 42: }) 43: } catch (err) { 44: throw new DirectConnectError( 45: `Failed to connect to server at ${serverUrl}: ${errorMessage(err)}`, 46: ) 47: } 48: if (!resp.ok) { 49: throw new DirectConnectError( 50: `Failed to create session: ${resp.status} ${resp.statusText}`, 51: ) 52: } 53: const result = connectResponseSchema().safeParse(await resp.json()) 54: if (!result.success) { 55: throw new DirectConnectError( 56: `Invalid session response: ${result.error.message}`, 57: ) 58: } 59: const data = result.data 60: return { 61: config: { 62: serverUrl, 63: sessionId: data.session_id, 64: wsUrl: data.ws_url, 65: authToken, 66: }, 67: workDir: data.work_dir, 68: } 69: }

File: src/server/directConnectManager.ts

typescript 1: import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' 2: import type { 3: SDKControlPermissionRequest, 4: StdoutMessage, 5: } from '../entrypoints/sdk/controlTypes.js' 6: import type { RemotePermissionResponse } from '../remote/RemoteSessionManager.js' 7: import { logForDebugging } from '../utils/debug.js' 8: import { jsonParse, jsonStringify } from '../utils/slowOperations.js' 9: import type { RemoteMessageContent } from '../utils/teleport/api.js' 10: export type DirectConnectConfig = { 11: serverUrl: string 12: sessionId: string 13: wsUrl: string 14: authToken?: string 15: } 16: export type DirectConnectCallbacks = { 17: onMessage: (message: SDKMessage) => void 18: onPermissionRequest: ( 19: request: SDKControlPermissionRequest, 20: requestId: string, 21: ) => void 22: onConnected?: () => void 23: onDisconnected?: () => void 24: onError?: (error: Error) => void 25: } 26: function isStdoutMessage(value: unknown): value is StdoutMessage { 27: return ( 28: typeof value === 'object' && 29: value !== null && 30: 'type' in value && 31: typeof value.type === 'string' 32: ) 33: } 34: export class DirectConnectSessionManager { 35: private ws: WebSocket | null = null 36: private config: DirectConnectConfig 37: private callbacks: DirectConnectCallbacks 38: constructor(config: DirectConnectConfig, callbacks: DirectConnectCallbacks) { 39: this.config = config 40: this.callbacks = callbacks 41: } 42: connect(): void { 43: const headers: Record<string, string> = {} 44: if (this.config.authToken) { 45: headers['authorization'] = `Bearer ${this.config.authToken}` 46: } 47: this.ws = new WebSocket(this.config.wsUrl, { 48: headers, 49: } as unknown as string[]) 50: this.ws.addEventListener('open', () => { 51: this.callbacks.onConnected?.() 52: }) 53: this.ws.addEventListener('message', event => { 54: const data = typeof event.data === 'string' ? event.data : '' 55: const lines = data.split('\n').filter((l: string) => l.trim()) 56: for (const line of lines) { 57: let raw: unknown 58: try { 59: raw = jsonParse(line) 60: } catch { 61: continue 62: } 63: if (!isStdoutMessage(raw)) { 64: continue 65: } 66: const parsed = raw 67: if (parsed.type === 'control_request') { 68: if (parsed.request.subtype === 'can_use_tool') { 69: this.callbacks.onPermissionRequest( 70: parsed.request, 71: parsed.request_id, 72: ) 73: } else { 74: logForDebugging( 75: `[DirectConnect] Unsupported control request subtype: ${parsed.request.subtype}`, 76: ) 77: this.sendErrorResponse( 78: parsed.request_id, 79: `Unsupported control request subtype: ${parsed.request.subtype}`, 80: ) 81: } 82: continue 83: } 84: if ( 85: parsed.type !== 'control_response' && 86: parsed.type !== 'keep_alive' && 87: parsed.type !== 'control_cancel_request' && 88: parsed.type !== 'streamlined_text' && 89: parsed.type !== 'streamlined_tool_use_summary' && 90: !(parsed.type === 'system' && parsed.subtype === 'post_turn_summary') 91: ) { 92: this.callbacks.onMessage(parsed) 93: } 94: } 95: }) 96: this.ws.addEventListener('close', () => { 97: this.callbacks.onDisconnected?.() 98: }) 99: this.ws.addEventListener('error', () => { 100: this.callbacks.onError?.(new Error('WebSocket connection error')) 101: }) 102: } 103: sendMessage(content: RemoteMessageContent): boolean { 104: if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 105: return false 106: } 107: const message = jsonStringify({ 108: type: 'user', 109: message: { 110: role: 'user', 111: content: content, 112: }, 113: parent_tool_use_id: null, 114: session_id: '', 115: }) 116: this.ws.send(message) 117: return true 118: } 119: respondToPermissionRequest( 120: requestId: string, 121: result: RemotePermissionResponse, 122: ): void { 123: if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 124: return 125: } 126: // Must match SDKControlResponse format expected by StructuredIO 127: const response = jsonStringify({ 128: type: 'control_response', 129: response: { 130: subtype: 'success', 131: request_id: requestId, 132: response: { 133: behavior: result.behavior, 134: ...(result.behavior === 'allow' 135: ? { updatedInput: result.updatedInput } 136: : { message: result.message }), 137: }, 138: }, 139: }) 140: this.ws.send(response) 141: } 142: sendInterrupt(): void { 143: if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 144: return 145: } 146: const request = jsonStringify({ 147: type: 'control_request', 148: request_id: crypto.randomUUID(), 149: request: { 150: subtype: 'interrupt', 151: }, 152: }) 153: this.ws.send(request) 154: } 155: private sendErrorResponse(requestId: string, error: string): void { 156: if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { 157: return 158: } 159: const response = jsonStringify({ 160: type: 'control_response', 161: response: { 162: subtype: 'error', 163: request_id: requestId, 164: error, 165: }, 166: }) 167: this.ws.send(response) 168: } 169: disconnect(): void { 170: if (this.ws) { 171: this.ws.close() 172: this.ws = null 173: } 174: } 175: isConnected(): boolean { 176: return this.ws?.readyState === WebSocket.OPEN 177: } 178: }

File: src/server/types.ts

typescript 1: import type { ChildProcess } from 'child_process' 2: import { z } from 'zod/v4' 3: import { lazySchema } from '../utils/lazySchema.js' 4: export const connectResponseSchema = lazySchema(() => 5: z.object({ 6: session_id: z.string(), 7: ws_url: z.string(), 8: work_dir: z.string().optional(), 9: }), 10: ) 11: export type ServerConfig = { 12: port: number 13: host: string 14: authToken: string 15: unix?: string 16: idleTimeoutMs?: number 17: maxSessions?: number 18: workspace?: string 19: } 20: export type SessionState = 21: | 'starting' 22: | 'running' 23: | 'detached' 24: | 'stopping' 25: | 'stopped' 26: export type SessionInfo = { 27: id: string 28: status: SessionState 29: createdAt: number 30: workDir: string 31: process: ChildProcess | null 32: sessionKey?: string 33: } 34: export type SessionIndexEntry = { 35: sessionId: string 36: transcriptSessionId: string 37: cwd: string 38: permissionMode?: string 39: createdAt: number 40: lastActiveAt: number 41: } 42: export type SessionIndex = Record<string, SessionIndexEntry>

File: src/services/AgentSummary/agentSummary.ts

typescript 1: import type { TaskContext } from '../../Task.js' 2: import { updateAgentSummary } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 3: import { filterIncompleteToolCalls } from '../../tools/AgentTool/runAgent.js' 4: import type { AgentId } from '../../types/ids.js' 5: import { logForDebugging } from '../../utils/debug.js' 6: import { 7: type CacheSafeParams, 8: runForkedAgent, 9: } from '../../utils/forkedAgent.js' 10: import { logError } from '../../utils/log.js' 11: import { createUserMessage } from '../../utils/messages.js' 12: import { getAgentTranscript } from '../../utils/sessionStorage.js' 13: const SUMMARY_INTERVAL_MS = 30_000 14: function buildSummaryPrompt(previousSummary: string | null): string { 15: const prevLine = previousSummary 16: ? `\nPrevious: "${previousSummary}" — say something NEW.\n` 17: : '' 18: return `Describe your most recent action in 3-5 words using present tense (-ing). Name the file or function, not the branch. Do not use tools. 19: ${prevLine} 20: Good: "Reading runAgent.ts" 21: Good: "Fixing null check in validate.ts" 22: Good: "Running auth module tests" 23: Good: "Adding retry logic to fetchUser" 24: Bad (past tense): "Analyzed the branch diff" 25: Bad (too vague): "Investigating the issue" 26: Bad (too long): "Reviewing full branch diff and AgentTool.tsx integration" 27: Bad (branch name): "Analyzed adam/background-summary branch diff"` 28: } 29: export function startAgentSummarization( 30: taskId: string, 31: agentId: AgentId, 32: cacheSafeParams: CacheSafeParams, 33: setAppState: TaskContext['setAppState'], 34: ): { stop: () => void } { 35: const { forkContextMessages: _drop, ...baseParams } = cacheSafeParams 36: let summaryAbortController: AbortController | null = null 37: let timeoutId: ReturnType<typeof setTimeout> | null = null 38: let stopped = false 39: let previousSummary: string | null = null 40: async function runSummary(): Promise<void> { 41: if (stopped) return 42: logForDebugging(`[AgentSummary] Timer fired for agent ${agentId}`) 43: try { 44: const transcript = await getAgentTranscript(agentId) 45: if (!transcript || transcript.messages.length < 3) { 46: logForDebugging( 47: `[AgentSummary] Skipping summary for ${taskId}: not enough messages (${transcript?.messages.length ?? 0})`, 48: ) 49: return 50: } 51: const cleanMessages = filterIncompleteToolCalls(transcript.messages) 52: const forkParams: CacheSafeParams = { 53: ...baseParams, 54: forkContextMessages: cleanMessages, 55: } 56: logForDebugging( 57: `[AgentSummary] Forking for summary, ${cleanMessages.length} messages in context`, 58: ) 59: summaryAbortController = new AbortController() 60: const canUseTool = async () => ({ 61: behavior: 'deny' as const, 62: message: 'No tools needed for summary', 63: decisionReason: { type: 'other' as const, reason: 'summary only' }, 64: }) 65: const result = await runForkedAgent({ 66: promptMessages: [ 67: createUserMessage({ content: buildSummaryPrompt(previousSummary) }), 68: ], 69: cacheSafeParams: forkParams, 70: canUseTool, 71: querySource: 'agent_summary', 72: forkLabel: 'agent_summary', 73: overrides: { abortController: summaryAbortController }, 74: skipTranscript: true, 75: }) 76: if (stopped) return 77: for (const msg of result.messages) { 78: if (msg.type !== 'assistant') continue 79: if (msg.isApiErrorMessage) { 80: logForDebugging( 81: `[AgentSummary] Skipping API error message for ${taskId}`, 82: ) 83: continue 84: } 85: const textBlock = msg.message.content.find(b => b.type === 'text') 86: if (textBlock?.type === 'text' && textBlock.text.trim()) { 87: const summaryText = textBlock.text.trim() 88: logForDebugging( 89: `[AgentSummary] Summary result for ${taskId}: ${summaryText}`, 90: ) 91: previousSummary = summaryText 92: updateAgentSummary(taskId, summaryText, setAppState) 93: break 94: } 95: } 96: } catch (e) { 97: if (!stopped && e instanceof Error) { 98: logError(e) 99: } 100: } finally { 101: summaryAbortController = null 102: if (!stopped) { 103: scheduleNext() 104: } 105: } 106: } 107: function scheduleNext(): void { 108: if (stopped) return 109: timeoutId = setTimeout(runSummary, SUMMARY_INTERVAL_MS) 110: } 111: function stop(): void { 112: logForDebugging(`[AgentSummary] Stopping summarization for ${taskId}`) 113: stopped = true 114: if (timeoutId) { 115: clearTimeout(timeoutId) 116: timeoutId = null 117: } 118: if (summaryAbortController) { 119: summaryAbortController.abort() 120: summaryAbortController = null 121: } 122: } 123: scheduleNext() 124: return { stop } 125: }

File: src/services/analytics/config.ts

typescript 1: import { isEnvTruthy } from '../../utils/envUtils.js' 2: import { isTelemetryDisabled } from '../../utils/privacyLevel.js' 3: export function isAnalyticsDisabled(): boolean { 4: return ( 5: process.env.NODE_ENV === 'test' || 6: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 7: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 8: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) || 9: isTelemetryDisabled() 10: ) 11: } 12: export function isFeedbackSurveyDisabled(): boolean { 13: return process.env.NODE_ENV === 'test' || isTelemetryDisabled() 14: }

File: src/services/analytics/datadog.ts

typescript 1: import axios from 'axios' 2: import { createHash } from 'crypto' 3: import memoize from 'lodash-es/memoize.js' 4: import { getOrCreateUserID } from '../../utils/config.js' 5: import { logError } from '../../utils/log.js' 6: import { getCanonicalName } from '../../utils/model/model.js' 7: import { getAPIProvider } from '../../utils/model/providers.js' 8: import { MODEL_COSTS } from '../../utils/modelCost.js' 9: import { isAnalyticsDisabled } from './config.js' 10: import { getEventMetadata } from './metadata.js' 11: const DATADOG_LOGS_ENDPOINT = 12: 'https://http-intake.logs.us5.datadoghq.com/api/v2/logs' 13: const DATADOG_CLIENT_TOKEN = 'pubbbf48e6d78dae54bceaa4acf463299bf' 14: const DEFAULT_FLUSH_INTERVAL_MS = 15000 15: const MAX_BATCH_SIZE = 100 16: const NETWORK_TIMEOUT_MS = 5000 17: const DATADOG_ALLOWED_EVENTS = new Set([ 18: 'chrome_bridge_connection_succeeded', 19: 'chrome_bridge_connection_failed', 20: 'chrome_bridge_disconnected', 21: 'chrome_bridge_tool_call_completed', 22: 'chrome_bridge_tool_call_error', 23: 'chrome_bridge_tool_call_started', 24: 'chrome_bridge_tool_call_timeout', 25: 'tengu_api_error', 26: 'tengu_api_success', 27: 'tengu_brief_mode_enabled', 28: 'tengu_brief_mode_toggled', 29: 'tengu_brief_send', 30: 'tengu_cancel', 31: 'tengu_compact_failed', 32: 'tengu_exit', 33: 'tengu_flicker', 34: 'tengu_init', 35: 'tengu_model_fallback_triggered', 36: 'tengu_oauth_error', 37: 'tengu_oauth_success', 38: 'tengu_oauth_token_refresh_failure', 39: 'tengu_oauth_token_refresh_success', 40: 'tengu_oauth_token_refresh_lock_acquiring', 41: 'tengu_oauth_token_refresh_lock_acquired', 42: 'tengu_oauth_token_refresh_starting', 43: 'tengu_oauth_token_refresh_completed', 44: 'tengu_oauth_token_refresh_lock_releasing', 45: 'tengu_oauth_token_refresh_lock_released', 46: 'tengu_query_error', 47: 'tengu_session_file_read', 48: 'tengu_started', 49: 'tengu_tool_use_error', 50: 'tengu_tool_use_granted_in_prompt_permanent', 51: 'tengu_tool_use_granted_in_prompt_temporary', 52: 'tengu_tool_use_rejected_in_prompt', 53: 'tengu_tool_use_success', 54: 'tengu_uncaught_exception', 55: 'tengu_unhandled_rejection', 56: 'tengu_voice_recording_started', 57: 'tengu_voice_toggled', 58: 'tengu_team_mem_sync_pull', 59: 'tengu_team_mem_sync_push', 60: 'tengu_team_mem_sync_started', 61: 'tengu_team_mem_entries_capped', 62: ]) 63: const TAG_FIELDS = [ 64: 'arch', 65: 'clientType', 66: 'errorType', 67: 'http_status_range', 68: 'http_status', 69: 'kairosActive', 70: 'model', 71: 'platform', 72: 'provider', 73: 'skillMode', 74: 'subscriptionType', 75: 'toolName', 76: 'userBucket', 77: 'userType', 78: 'version', 79: 'versionBase', 80: ] 81: function camelToSnakeCase(str: string): string { 82: return str.replace(/[A-Z]/g, letter => `_${letter.toLowerCase()}`) 83: } 84: type DatadogLog = { 85: ddsource: string 86: ddtags: string 87: message: string 88: service: string 89: hostname: string 90: [key: string]: unknown 91: } 92: let logBatch: DatadogLog[] = [] 93: let flushTimer: NodeJS.Timeout | null = null 94: let datadogInitialized: boolean | null = null 95: async function flushLogs(): Promise<void> { 96: if (logBatch.length === 0) return 97: const logsToSend = logBatch 98: logBatch = [] 99: try { 100: await axios.post(DATADOG_LOGS_ENDPOINT, logsToSend, { 101: headers: { 102: 'Content-Type': 'application/json', 103: 'DD-API-KEY': DATADOG_CLIENT_TOKEN, 104: }, 105: timeout: NETWORK_TIMEOUT_MS, 106: }) 107: } catch (error) { 108: logError(error) 109: } 110: } 111: function scheduleFlush(): void { 112: if (flushTimer) return 113: flushTimer = setTimeout(() => { 114: flushTimer = null 115: void flushLogs() 116: }, getFlushIntervalMs()).unref() 117: } 118: export const initializeDatadog = memoize(async (): Promise<boolean> => { 119: if (isAnalyticsDisabled()) { 120: datadogInitialized = false 121: return false 122: } 123: try { 124: datadogInitialized = true 125: return true 126: } catch (error) { 127: logError(error) 128: datadogInitialized = false 129: return false 130: } 131: }) 132: export async function shutdownDatadog(): Promise<void> { 133: if (flushTimer) { 134: clearTimeout(flushTimer) 135: flushTimer = null 136: } 137: await flushLogs() 138: } 139: export async function trackDatadogEvent( 140: eventName: string, 141: properties: { [key: string]: boolean | number | undefined }, 142: ): Promise<void> { 143: if (process.env.NODE_ENV !== 'production') { 144: return 145: } 146: if (getAPIProvider() !== 'firstParty') { 147: return 148: } 149: let initialized = datadogInitialized 150: if (initialized === null) { 151: initialized = await initializeDatadog() 152: } 153: if (!initialized || !DATADOG_ALLOWED_EVENTS.has(eventName)) { 154: return 155: } 156: try { 157: const metadata = await getEventMetadata({ 158: model: properties.model, 159: betas: properties.betas, 160: }) 161: const { envContext, ...restMetadata } = metadata 162: const allData: Record<string, unknown> = { 163: ...restMetadata, 164: ...envContext, 165: ...properties, 166: userBucket: getUserBucket(), 167: } 168: if ( 169: typeof allData.toolName === 'string' && 170: allData.toolName.startsWith('mcp__') 171: ) { 172: allData.toolName = 'mcp' 173: } 174: if (process.env.USER_TYPE !== 'ant' && typeof allData.model === 'string') { 175: const shortName = getCanonicalName(allData.model.replace(/\[1m]$/i, '')) 176: allData.model = shortName in MODEL_COSTS ? shortName : 'other' 177: } 178: if (typeof allData.version === 'string') { 179: allData.version = allData.version.replace( 180: /^(\d+\.\d+\.\d+-dev\.\d{8})\.t\d+\.sha[a-f0-9]+$/, 181: '$1', 182: ) 183: } 184: if (allData.status !== undefined && allData.status !== null) { 185: const statusCode = String(allData.status) 186: allData.http_status = statusCode 187: const firstDigit = statusCode.charAt(0) 188: if (firstDigit >= '1' && firstDigit <= '5') { 189: allData.http_status_range = `${firstDigit}xx` 190: } 191: delete allData.status 192: } 193: const allDataRecord = allData 194: const tags = [ 195: `event:${eventName}`, 196: ...TAG_FIELDS.filter( 197: field => 198: allDataRecord[field] !== undefined && allDataRecord[field] !== null, 199: ).map(field => `${camelToSnakeCase(field)}:${allDataRecord[field]}`), 200: ] 201: const log: DatadogLog = { 202: ddsource: 'nodejs', 203: ddtags: tags.join(','), 204: message: eventName, 205: service: 'claude-code', 206: hostname: 'claude-code', 207: env: process.env.USER_TYPE, 208: } 209: for (const [key, value] of Object.entries(allData)) { 210: if (value !== undefined && value !== null) { 211: log[camelToSnakeCase(key)] = value 212: } 213: } 214: logBatch.push(log) 215: if (logBatch.length >= MAX_BATCH_SIZE) { 216: if (flushTimer) { 217: clearTimeout(flushTimer) 218: flushTimer = null 219: } 220: void flushLogs() 221: } else { 222: scheduleFlush() 223: } 224: } catch (error) { 225: logError(error) 226: } 227: } 228: const NUM_USER_BUCKETS = 30 229: const getUserBucket = memoize((): number => { 230: const userId = getOrCreateUserID() 231: const hash = createHash('sha256').update(userId).digest('hex') 232: return parseInt(hash.slice(0, 8), 16) % NUM_USER_BUCKETS 233: }) 234: function getFlushIntervalMs(): number { 235: return ( 236: parseInt(process.env.CLAUDE_CODE_DATADOG_FLUSH_INTERVAL_MS || '', 10) || 237: DEFAULT_FLUSH_INTERVAL_MS 238: ) 239: }

File: src/services/analytics/firstPartyEventLogger.ts

typescript 1: import type { AnyValueMap, Logger, logs } from '@opentelemetry/api-logs' 2: import { resourceFromAttributes } from '@opentelemetry/resources' 3: import { 4: BatchLogRecordProcessor, 5: LoggerProvider, 6: } from '@opentelemetry/sdk-logs' 7: import { 8: ATTR_SERVICE_NAME, 9: ATTR_SERVICE_VERSION, 10: } from '@opentelemetry/semantic-conventions' 11: import { randomUUID } from 'crypto' 12: import { isEqual } from 'lodash-es' 13: import { getOrCreateUserID } from '../../utils/config.js' 14: import { logForDebugging } from '../../utils/debug.js' 15: import { logError } from '../../utils/log.js' 16: import { getPlatform, getWslVersion } from '../../utils/platform.js' 17: import { jsonStringify } from '../../utils/slowOperations.js' 18: import { profileCheckpoint } from '../../utils/startupProfiler.js' 19: import { getCoreUserData } from '../../utils/user.js' 20: import { isAnalyticsDisabled } from './config.js' 21: import { FirstPartyEventLoggingExporter } from './firstPartyEventLoggingExporter.js' 22: import type { GrowthBookUserAttributes } from './growthbook.js' 23: import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js' 24: import { getEventMetadata } from './metadata.js' 25: import { isSinkKilled } from './sinkKillswitch.js' 26: export type EventSamplingConfig = { 27: [eventName: string]: { 28: sample_rate: number 29: } 30: } 31: const EVENT_SAMPLING_CONFIG_NAME = 'tengu_event_sampling_config' 32: export function getEventSamplingConfig(): EventSamplingConfig { 33: return getDynamicConfig_CACHED_MAY_BE_STALE<EventSamplingConfig>( 34: EVENT_SAMPLING_CONFIG_NAME, 35: {}, 36: ) 37: } 38: export function shouldSampleEvent(eventName: string): number | null { 39: const config = getEventSamplingConfig() 40: const eventConfig = config[eventName] 41: if (!eventConfig) { 42: return null 43: } 44: const sampleRate = eventConfig.sample_rate 45: if (typeof sampleRate !== 'number' || sampleRate < 0 || sampleRate > 1) { 46: return null 47: } 48: if (sampleRate >= 1) { 49: return null 50: } 51: if (sampleRate <= 0) { 52: return 0 53: } 54: return Math.random() < sampleRate ? sampleRate : 0 55: } 56: const BATCH_CONFIG_NAME = 'tengu_1p_event_batch_config' 57: type BatchConfig = { 58: scheduledDelayMillis?: number 59: maxExportBatchSize?: number 60: maxQueueSize?: number 61: skipAuth?: boolean 62: maxAttempts?: number 63: path?: string 64: baseUrl?: string 65: } 66: function getBatchConfig(): BatchConfig { 67: return getDynamicConfig_CACHED_MAY_BE_STALE<BatchConfig>( 68: BATCH_CONFIG_NAME, 69: {}, 70: ) 71: } 72: let firstPartyEventLogger: ReturnType<typeof logs.getLogger> | null = null 73: let firstPartyEventLoggerProvider: LoggerProvider | null = null 74: let lastBatchConfig: BatchConfig | null = null 75: export async function shutdown1PEventLogging(): Promise<void> { 76: if (!firstPartyEventLoggerProvider) { 77: return 78: } 79: try { 80: await firstPartyEventLoggerProvider.shutdown() 81: if (process.env.USER_TYPE === 'ant') { 82: logForDebugging('1P event logging: final shutdown complete') 83: } 84: } catch { 85: } 86: } 87: export function is1PEventLoggingEnabled(): boolean { 88: return !isAnalyticsDisabled() 89: } 90: async function logEventTo1PAsync( 91: firstPartyEventLogger: Logger, 92: eventName: string, 93: metadata: Record<string, number | boolean | undefined> = {}, 94: ): Promise<void> { 95: try { 96: const coreMetadata = await getEventMetadata({ 97: model: metadata.model, 98: betas: metadata.betas, 99: }) 100: const attributes = { 101: event_name: eventName, 102: event_id: randomUUID(), 103: core_metadata: coreMetadata, 104: user_metadata: getCoreUserData(true), 105: event_metadata: metadata, 106: } as unknown as AnyValueMap 107: const userId = getOrCreateUserID() 108: if (userId) { 109: attributes.user_id = userId 110: } 111: if (process.env.USER_TYPE === 'ant') { 112: logForDebugging( 113: `[ANT-ONLY] 1P event: ${eventName} ${jsonStringify(metadata, null, 0)}`, 114: ) 115: } 116: firstPartyEventLogger.emit({ 117: body: eventName, 118: attributes, 119: }) 120: } catch (e) { 121: if (process.env.NODE_ENV === 'development') { 122: throw e 123: } 124: if (process.env.USER_TYPE === 'ant') { 125: logError(e as Error) 126: } 127: } 128: } 129: export function logEventTo1P( 130: eventName: string, 131: metadata: Record<string, number | boolean | undefined> = {}, 132: ): void { 133: if (!is1PEventLoggingEnabled()) { 134: return 135: } 136: if (!firstPartyEventLogger || isSinkKilled('firstParty')) { 137: return 138: } 139: void logEventTo1PAsync(firstPartyEventLogger, eventName, metadata) 140: } 141: export type GrowthBookExperimentData = { 142: experimentId: string 143: variationId: number 144: userAttributes?: GrowthBookUserAttributes 145: experimentMetadata?: Record<string, unknown> 146: } 147: function getEnvironmentForGrowthBook(): string { 148: return 'production' 149: } 150: export function logGrowthBookExperimentTo1P( 151: data: GrowthBookExperimentData, 152: ): void { 153: if (!is1PEventLoggingEnabled()) { 154: return 155: } 156: if (!firstPartyEventLogger || isSinkKilled('firstParty')) { 157: return 158: } 159: const userId = getOrCreateUserID() 160: const { accountUuid, organizationUuid } = getCoreUserData(true) 161: const attributes = { 162: event_type: 'GrowthbookExperimentEvent', 163: event_id: randomUUID(), 164: experiment_id: data.experimentId, 165: variation_id: data.variationId, 166: ...(userId && { device_id: userId }), 167: ...(accountUuid && { account_uuid: accountUuid }), 168: ...(organizationUuid && { organization_uuid: organizationUuid }), 169: ...(data.userAttributes && { 170: session_id: data.userAttributes.sessionId, 171: user_attributes: jsonStringify(data.userAttributes), 172: }), 173: ...(data.experimentMetadata && { 174: experiment_metadata: jsonStringify(data.experimentMetadata), 175: }), 176: environment: getEnvironmentForGrowthBook(), 177: } 178: if (process.env.USER_TYPE === 'ant') { 179: logForDebugging( 180: `[ANT-ONLY] 1P GrowthBook experiment: ${data.experimentId} variation=${data.variationId}`, 181: ) 182: } 183: firstPartyEventLogger.emit({ 184: body: 'growthbook_experiment', 185: attributes, 186: }) 187: } 188: const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 10000 189: const DEFAULT_MAX_EXPORT_BATCH_SIZE = 200 190: const DEFAULT_MAX_QUEUE_SIZE = 8192 191: export function initialize1PEventLogging(): void { 192: profileCheckpoint('1p_event_logging_start') 193: const enabled = is1PEventLoggingEnabled() 194: if (!enabled) { 195: if (process.env.USER_TYPE === 'ant') { 196: logForDebugging('1P event logging not enabled') 197: } 198: return 199: } 200: const batchConfig = getBatchConfig() 201: lastBatchConfig = batchConfig 202: profileCheckpoint('1p_event_after_growthbook_config') 203: const scheduledDelayMillis = 204: batchConfig.scheduledDelayMillis || 205: parseInt( 206: process.env.OTEL_LOGS_EXPORT_INTERVAL || 207: DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), 208: ) 209: const maxExportBatchSize = 210: batchConfig.maxExportBatchSize || DEFAULT_MAX_EXPORT_BATCH_SIZE 211: const maxQueueSize = batchConfig.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE 212: const platform = getPlatform() 213: const attributes: Record<string, string> = { 214: [ATTR_SERVICE_NAME]: 'claude-code', 215: [ATTR_SERVICE_VERSION]: MACRO.VERSION, 216: } 217: if (platform === 'wsl') { 218: const wslVersion = getWslVersion() 219: if (wslVersion) { 220: attributes['wsl.version'] = wslVersion 221: } 222: } 223: const resource = resourceFromAttributes(attributes) 224: const eventLoggingExporter = new FirstPartyEventLoggingExporter({ 225: maxBatchSize: maxExportBatchSize, 226: skipAuth: batchConfig.skipAuth, 227: maxAttempts: batchConfig.maxAttempts, 228: path: batchConfig.path, 229: baseUrl: batchConfig.baseUrl, 230: isKilled: () => isSinkKilled('firstParty'), 231: }) 232: firstPartyEventLoggerProvider = new LoggerProvider({ 233: resource, 234: processors: [ 235: new BatchLogRecordProcessor(eventLoggingExporter, { 236: scheduledDelayMillis, 237: maxExportBatchSize, 238: maxQueueSize, 239: }), 240: ], 241: }) 242: firstPartyEventLogger = firstPartyEventLoggerProvider.getLogger( 243: 'com.anthropic.claude_code.events', 244: MACRO.VERSION, 245: ) 246: } 247: export async function reinitialize1PEventLoggingIfConfigChanged(): Promise<void> { 248: if (!is1PEventLoggingEnabled() || !firstPartyEventLoggerProvider) { 249: return 250: } 251: const newConfig = getBatchConfig() 252: if (isEqual(newConfig, lastBatchConfig)) { 253: return 254: } 255: if (process.env.USER_TYPE === 'ant') { 256: logForDebugging( 257: `1P event logging: ${BATCH_CONFIG_NAME} changed, reinitializing`, 258: ) 259: } 260: const oldProvider = firstPartyEventLoggerProvider 261: const oldLogger = firstPartyEventLogger 262: firstPartyEventLogger = null 263: try { 264: await oldProvider.forceFlush() 265: } catch { 266: } 267: firstPartyEventLoggerProvider = null 268: try { 269: initialize1PEventLogging() 270: } catch (e) { 271: firstPartyEventLoggerProvider = oldProvider 272: firstPartyEventLogger = oldLogger 273: logError(e) 274: return 275: } 276: void oldProvider.shutdown().catch(() => {}) 277: }

File: src/services/analytics/firstPartyEventLoggingExporter.ts

typescript 1: import type { HrTime } from '@opentelemetry/api' 2: import { type ExportResult, ExportResultCode } from '@opentelemetry/core' 3: import type { 4: LogRecordExporter, 5: ReadableLogRecord, 6: } from '@opentelemetry/sdk-logs' 7: import axios from 'axios' 8: import { randomUUID } from 'crypto' 9: import { appendFile, mkdir, readdir, unlink, writeFile } from 'fs/promises' 10: import * as path from 'path' 11: import type { CoreUserData } from 'src/utils/user.js' 12: import { 13: getIsNonInteractiveSession, 14: getSessionId, 15: } from '../../bootstrap/state.js' 16: import { ClaudeCodeInternalEvent } from '../../types/generated/events_mono/claude_code/v1/claude_code_internal_event.js' 17: import { GrowthbookExperimentEvent } from '../../types/generated/events_mono/growthbook/v1/growthbook_experiment_event.js' 18: import { 19: getClaudeAIOAuthTokens, 20: hasProfileScope, 21: isClaudeAISubscriber, 22: } from '../../utils/auth.js' 23: import { checkHasTrustDialogAccepted } from '../../utils/config.js' 24: import { logForDebugging } from '../../utils/debug.js' 25: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 26: import { errorMessage, isFsInaccessible, toError } from '../../utils/errors.js' 27: import { getAuthHeaders } from '../../utils/http.js' 28: import { readJSONLFile } from '../../utils/json.js' 29: import { logError } from '../../utils/log.js' 30: import { sleep } from '../../utils/sleep.js' 31: import { jsonStringify } from '../../utils/slowOperations.js' 32: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 33: import { isOAuthTokenExpired } from '../oauth/client.js' 34: import { stripProtoFields } from './index.js' 35: import { type EventMetadata, to1PEventFormat } from './metadata.js' 36: const BATCH_UUID = randomUUID() 37: const FILE_PREFIX = '1p_failed_events.' 38: function getStorageDir(): string { 39: return path.join(getClaudeConfigHomeDir(), 'telemetry') 40: } 41: type FirstPartyEventLoggingEvent = { 42: event_type: 'ClaudeCodeInternalEvent' | 'GrowthbookExperimentEvent' 43: event_data: unknown 44: } 45: type FirstPartyEventLoggingPayload = { 46: events: FirstPartyEventLoggingEvent[] 47: } 48: export class FirstPartyEventLoggingExporter implements LogRecordExporter { 49: private readonly endpoint: string 50: private readonly timeout: number 51: private readonly maxBatchSize: number 52: private readonly skipAuth: boolean 53: private readonly batchDelayMs: number 54: private readonly baseBackoffDelayMs: number 55: private readonly maxBackoffDelayMs: number 56: private readonly maxAttempts: number 57: private readonly isKilled: () => boolean 58: private pendingExports: Promise<void>[] = [] 59: private isShutdown = false 60: private readonly schedule: ( 61: fn: () => Promise<void>, 62: delayMs: number, 63: ) => () => void 64: private cancelBackoff: (() => void) | null = null 65: private attempts = 0 66: private isRetrying = false 67: private lastExportErrorContext: string | undefined 68: constructor( 69: options: { 70: timeout?: number 71: maxBatchSize?: number 72: skipAuth?: boolean 73: batchDelayMs?: number 74: baseBackoffDelayMs?: number 75: maxBackoffDelayMs?: number 76: maxAttempts?: number 77: path?: string 78: baseUrl?: string 79: isKilled?: () => boolean 80: schedule?: (fn: () => Promise<void>, delayMs: number) => () => void 81: } = {}, 82: ) { 83: const baseUrl = 84: options.baseUrl || 85: (process.env.ANTHROPIC_BASE_URL === 'https://api-staging.anthropic.com' 86: ? 'https://api-staging.anthropic.com' 87: : 'https://api.anthropic.com') 88: this.endpoint = `${baseUrl}${options.path || '/api/event_logging/batch'}` 89: this.timeout = options.timeout || 10000 90: this.maxBatchSize = options.maxBatchSize || 200 91: this.skipAuth = options.skipAuth ?? false 92: this.batchDelayMs = options.batchDelayMs || 100 93: this.baseBackoffDelayMs = options.baseBackoffDelayMs || 500 94: this.maxBackoffDelayMs = options.maxBackoffDelayMs || 30000 95: this.maxAttempts = options.maxAttempts ?? 8 96: this.isKilled = options.isKilled ?? (() => false) 97: this.schedule = 98: options.schedule ?? 99: ((fn, ms) => { 100: const t = setTimeout(fn, ms) 101: return () => clearTimeout(t) 102: }) 103: void this.retryPreviousBatches() 104: } 105: async getQueuedEventCount(): Promise<number> { 106: return (await this.loadEventsFromCurrentBatch()).length 107: } 108: private getCurrentBatchFilePath(): string { 109: return path.join( 110: getStorageDir(), 111: `${FILE_PREFIX}${getSessionId()}.${BATCH_UUID}.json`, 112: ) 113: } 114: private async loadEventsFromFile( 115: filePath: string, 116: ): Promise<FirstPartyEventLoggingEvent[]> { 117: try { 118: return await readJSONLFile<FirstPartyEventLoggingEvent>(filePath) 119: } catch { 120: return [] 121: } 122: } 123: private async loadEventsFromCurrentBatch(): Promise< 124: FirstPartyEventLoggingEvent[] 125: > { 126: return this.loadEventsFromFile(this.getCurrentBatchFilePath()) 127: } 128: private async saveEventsToFile( 129: filePath: string, 130: events: FirstPartyEventLoggingEvent[], 131: ): Promise<void> { 132: try { 133: if (events.length === 0) { 134: try { 135: await unlink(filePath) 136: } catch { 137: } 138: } else { 139: await mkdir(getStorageDir(), { recursive: true }) 140: const content = events.map(e => jsonStringify(e)).join('\n') + '\n' 141: await writeFile(filePath, content, 'utf8') 142: } 143: } catch (error) { 144: logError(error) 145: } 146: } 147: private async appendEventsToFile( 148: filePath: string, 149: events: FirstPartyEventLoggingEvent[], 150: ): Promise<void> { 151: if (events.length === 0) return 152: try { 153: await mkdir(getStorageDir(), { recursive: true }) 154: const content = events.map(e => jsonStringify(e)).join('\n') + '\n' 155: await appendFile(filePath, content, 'utf8') 156: } catch (error) { 157: logError(error) 158: } 159: } 160: private async deleteFile(filePath: string): Promise<void> { 161: try { 162: await unlink(filePath) 163: } catch { 164: } 165: } 166: private async retryPreviousBatches(): Promise<void> { 167: try { 168: const prefix = `${FILE_PREFIX}${getSessionId()}.` 169: let files: string[] 170: try { 171: files = (await readdir(getStorageDir())) 172: .filter((f: string) => f.startsWith(prefix) && f.endsWith('.json')) 173: .filter((f: string) => !f.includes(BATCH_UUID)) 174: } catch (e) { 175: if (isFsInaccessible(e)) return 176: throw e 177: } 178: for (const file of files) { 179: const filePath = path.join(getStorageDir(), file) 180: void this.retryFileInBackground(filePath) 181: } 182: } catch (error) { 183: logError(error) 184: } 185: } 186: private async retryFileInBackground(filePath: string): Promise<void> { 187: if (this.attempts >= this.maxAttempts) { 188: await this.deleteFile(filePath) 189: return 190: } 191: const events = await this.loadEventsFromFile(filePath) 192: if (events.length === 0) { 193: await this.deleteFile(filePath) 194: return 195: } 196: if (process.env.USER_TYPE === 'ant') { 197: logForDebugging( 198: `1P event logging: retrying ${events.length} events from previous batch`, 199: ) 200: } 201: const failedEvents = await this.sendEventsInBatches(events) 202: if (failedEvents.length === 0) { 203: await this.deleteFile(filePath) 204: if (process.env.USER_TYPE === 'ant') { 205: logForDebugging('1P event logging: previous batch retry succeeded') 206: } 207: } else { 208: await this.saveEventsToFile(filePath, failedEvents) 209: if (process.env.USER_TYPE === 'ant') { 210: logForDebugging( 211: `1P event logging: previous batch retry failed, ${failedEvents.length} events remain`, 212: ) 213: } 214: } 215: } 216: async export( 217: logs: ReadableLogRecord[], 218: resultCallback: (result: ExportResult) => void, 219: ): Promise<void> { 220: if (this.isShutdown) { 221: if (process.env.USER_TYPE === 'ant') { 222: logForDebugging( 223: '1P event logging export failed: Exporter has been shutdown', 224: ) 225: } 226: resultCallback({ 227: code: ExportResultCode.FAILED, 228: error: new Error('Exporter has been shutdown'), 229: }) 230: return 231: } 232: const exportPromise = this.doExport(logs, resultCallback) 233: this.pendingExports.push(exportPromise) 234: void exportPromise.finally(() => { 235: const index = this.pendingExports.indexOf(exportPromise) 236: if (index > -1) { 237: void this.pendingExports.splice(index, 1) 238: } 239: }) 240: } 241: private async doExport( 242: logs: ReadableLogRecord[], 243: resultCallback: (result: ExportResult) => void, 244: ): Promise<void> { 245: try { 246: const eventLogs = logs.filter( 247: log => 248: log.instrumentationScope?.name === 'com.anthropic.claude_code.events', 249: ) 250: if (eventLogs.length === 0) { 251: resultCallback({ code: ExportResultCode.SUCCESS }) 252: return 253: } 254: const events = this.transformLogsToEvents(eventLogs).events 255: if (events.length === 0) { 256: resultCallback({ code: ExportResultCode.SUCCESS }) 257: return 258: } 259: if (this.attempts >= this.maxAttempts) { 260: resultCallback({ 261: code: ExportResultCode.FAILED, 262: error: new Error( 263: `Dropped ${events.length} events: max attempts (${this.maxAttempts}) reached`, 264: ), 265: }) 266: return 267: } 268: const failedEvents = await this.sendEventsInBatches(events) 269: this.attempts++ 270: if (failedEvents.length > 0) { 271: await this.queueFailedEvents(failedEvents) 272: this.scheduleBackoffRetry() 273: const context = this.lastExportErrorContext 274: ? ` (${this.lastExportErrorContext})` 275: : '' 276: resultCallback({ 277: code: ExportResultCode.FAILED, 278: error: new Error( 279: `Failed to export ${failedEvents.length} events${context}`, 280: ), 281: }) 282: return 283: } 284: // Success - reset backoff and immediately retry any queued events 285: this.resetBackoff() 286: if ((await this.getQueuedEventCount()) > 0 && !this.isRetrying) { 287: void this.retryFailedEvents() 288: } 289: resultCallback({ code: ExportResultCode.SUCCESS }) 290: } catch (error) { 291: if (process.env.USER_TYPE === 'ant') { 292: logForDebugging( 293: `1P event logging export failed: ${errorMessage(error)}`, 294: ) 295: } 296: logError(error) 297: resultCallback({ 298: code: ExportResultCode.FAILED, 299: error: toError(error), 300: }) 301: } 302: } 303: private async sendEventsInBatches( 304: events: FirstPartyEventLoggingEvent[], 305: ): Promise<FirstPartyEventLoggingEvent[]> { 306: const batches: FirstPartyEventLoggingEvent[][] = [] 307: for (let i = 0; i < events.length; i += this.maxBatchSize) { 308: batches.push(events.slice(i, i + this.maxBatchSize)) 309: } 310: if (process.env.USER_TYPE === 'ant') { 311: logForDebugging( 312: `1P event logging: exporting ${events.length} events in ${batches.length} batch(es)`, 313: ) 314: } 315: const failedBatchEvents: FirstPartyEventLoggingEvent[] = [] 316: let lastErrorContext: string | undefined 317: for (let i = 0; i < batches.length; i++) { 318: const batch = batches[i]! 319: try { 320: await this.sendBatchWithRetry({ events: batch }) 321: } catch (error) { 322: lastErrorContext = getAxiosErrorContext(error) 323: for (let j = i; j < batches.length; j++) { 324: failedBatchEvents.push(...batches[j]!) 325: } 326: if (process.env.USER_TYPE === 'ant') { 327: const skipped = batches.length - 1 - i 328: logForDebugging( 329: `1P event logging: batch ${i + 1}/${batches.length} failed (${lastErrorContext}); short-circuiting ${skipped} remaining batch(es)`, 330: ) 331: } 332: break 333: } 334: if (i < batches.length - 1 && this.batchDelayMs > 0) { 335: await sleep(this.batchDelayMs) 336: } 337: } 338: if (failedBatchEvents.length > 0 && lastErrorContext) { 339: this.lastExportErrorContext = lastErrorContext 340: } 341: return failedBatchEvents 342: } 343: private async queueFailedEvents( 344: events: FirstPartyEventLoggingEvent[], 345: ): Promise<void> { 346: const filePath = this.getCurrentBatchFilePath() 347: await this.appendEventsToFile(filePath, events) 348: const context = this.lastExportErrorContext 349: ? ` (${this.lastExportErrorContext})` 350: : '' 351: const message = `1P event logging: ${events.length} events failed to export${context}` 352: logError(new Error(message)) 353: } 354: private scheduleBackoffRetry(): void { 355: // Don't schedule if already retrying or shutdown 356: if (this.cancelBackoff || this.isRetrying || this.isShutdown) { 357: return 358: } 359: const delay = Math.min( 360: this.baseBackoffDelayMs * this.attempts * this.attempts, 361: this.maxBackoffDelayMs, 362: ) 363: if (process.env.USER_TYPE === 'ant') { 364: logForDebugging( 365: `1P event logging: scheduling backoff retry in ${delay}ms (attempt ${this.attempts})`, 366: ) 367: } 368: this.cancelBackoff = this.schedule(async () => { 369: this.cancelBackoff = null 370: await this.retryFailedEvents() 371: }, delay) 372: } 373: private async retryFailedEvents(): Promise<void> { 374: const filePath = this.getCurrentBatchFilePath() 375: while (!this.isShutdown) { 376: const events = await this.loadEventsFromFile(filePath) 377: if (events.length === 0) break 378: if (this.attempts >= this.maxAttempts) { 379: if (process.env.USER_TYPE === 'ant') { 380: logForDebugging( 381: `1P event logging: max attempts (${this.maxAttempts}) reached, dropping ${events.length} events`, 382: ) 383: } 384: await this.deleteFile(filePath) 385: this.resetBackoff() 386: return 387: } 388: this.isRetrying = true 389: await this.deleteFile(filePath) 390: if (process.env.USER_TYPE === 'ant') { 391: logForDebugging( 392: `1P event logging: retrying ${events.length} failed events (attempt ${this.attempts + 1})`, 393: ) 394: } 395: const failedEvents = await this.sendEventsInBatches(events) 396: this.attempts++ 397: this.isRetrying = false 398: if (failedEvents.length > 0) { 399: await this.saveEventsToFile(filePath, failedEvents) 400: this.scheduleBackoffRetry() 401: return 402: } 403: this.resetBackoff() 404: if (process.env.USER_TYPE === 'ant') { 405: logForDebugging('1P event logging: backoff retry succeeded') 406: } 407: } 408: } 409: private resetBackoff(): void { 410: this.attempts = 0 411: if (this.cancelBackoff) { 412: this.cancelBackoff() 413: this.cancelBackoff = null 414: } 415: } 416: private async sendBatchWithRetry( 417: payload: FirstPartyEventLoggingPayload, 418: ): Promise<void> { 419: if (this.isKilled()) { 420: throw new Error('firstParty sink killswitch active') 421: } 422: const baseHeaders: Record<string, string> = { 423: 'Content-Type': 'application/json', 424: 'User-Agent': getClaudeCodeUserAgent(), 425: 'x-service-name': 'claude-code', 426: } 427: const hasTrust = 428: checkHasTrustDialogAccepted() || getIsNonInteractiveSession() 429: if (process.env.USER_TYPE === 'ant' && !hasTrust) { 430: logForDebugging('1P event logging: Trust not accepted') 431: } 432: let shouldSkipAuth = this.skipAuth || !hasTrust 433: if (!shouldSkipAuth && isClaudeAISubscriber()) { 434: const tokens = getClaudeAIOAuthTokens() 435: if (!hasProfileScope()) { 436: shouldSkipAuth = true 437: } else if (tokens && isOAuthTokenExpired(tokens.expiresAt)) { 438: shouldSkipAuth = true 439: if (process.env.USER_TYPE === 'ant') { 440: logForDebugging( 441: '1P event logging: OAuth token expired, skipping auth to avoid 401', 442: ) 443: } 444: } 445: } 446: const authResult = shouldSkipAuth 447: ? { headers: {}, error: 'trust not established or Oauth token expired' } 448: : getAuthHeaders() 449: const useAuth = !authResult.error 450: if (!useAuth && process.env.USER_TYPE === 'ant') { 451: logForDebugging( 452: `1P event logging: auth not available, sending without auth`, 453: ) 454: } 455: const headers = useAuth 456: ? { ...baseHeaders, ...authResult.headers } 457: : baseHeaders 458: try { 459: const response = await axios.post(this.endpoint, payload, { 460: timeout: this.timeout, 461: headers, 462: }) 463: this.logSuccess(payload.events.length, useAuth, response.data) 464: return 465: } catch (error) { 466: if ( 467: useAuth && 468: axios.isAxiosError(error) && 469: error.response?.status === 401 470: ) { 471: if (process.env.USER_TYPE === 'ant') { 472: logForDebugging( 473: '1P event logging: 401 auth error, retrying without auth', 474: ) 475: } 476: const response = await axios.post(this.endpoint, payload, { 477: timeout: this.timeout, 478: headers: baseHeaders, 479: }) 480: this.logSuccess(payload.events.length, false, response.data) 481: return 482: } 483: throw error 484: } 485: } 486: private logSuccess( 487: eventCount: number, 488: withAuth: boolean, 489: responseData: unknown, 490: ): void { 491: if (process.env.USER_TYPE === 'ant') { 492: logForDebugging( 493: `1P event logging: ${eventCount} events exported successfully${withAuth ? ' (with auth)' : ' (without auth)'}`, 494: ) 495: logForDebugging(`API Response: ${jsonStringify(responseData, null, 2)}`) 496: } 497: } 498: private hrTimeToDate(hrTime: HrTime): Date { 499: const [seconds, nanoseconds] = hrTime 500: return new Date(seconds * 1000 + nanoseconds / 1000000) 501: } 502: private transformLogsToEvents( 503: logs: ReadableLogRecord[], 504: ): FirstPartyEventLoggingPayload { 505: const events: FirstPartyEventLoggingEvent[] = [] 506: for (const log of logs) { 507: const attributes = log.attributes || {} 508: if (attributes.event_type === 'GrowthbookExperimentEvent') { 509: const timestamp = this.hrTimeToDate(log.hrTime) 510: const account_uuid = attributes.account_uuid as string | undefined 511: const organization_uuid = attributes.organization_uuid as 512: | string 513: | undefined 514: events.push({ 515: event_type: 'GrowthbookExperimentEvent', 516: event_data: GrowthbookExperimentEvent.toJSON({ 517: event_id: attributes.event_id as string, 518: timestamp, 519: experiment_id: attributes.experiment_id as string, 520: variation_id: attributes.variation_id as number, 521: environment: attributes.environment as string, 522: user_attributes: attributes.user_attributes as string, 523: experiment_metadata: attributes.experiment_metadata as string, 524: device_id: attributes.device_id as string, 525: session_id: attributes.session_id as string, 526: auth: 527: account_uuid || organization_uuid 528: ? { account_uuid, organization_uuid } 529: : undefined, 530: }), 531: }) 532: continue 533: } 534: const eventName = 535: (attributes.event_name as string) || (log.body as string) || 'unknown' 536: const coreMetadata = attributes.core_metadata as EventMetadata | undefined 537: const userMetadata = attributes.user_metadata as CoreUserData 538: const eventMetadata = (attributes.event_metadata || {}) as Record< 539: string, 540: unknown 541: > 542: if (!coreMetadata) { 543: if (process.env.USER_TYPE === 'ant') { 544: logForDebugging( 545: `1P event logging: core_metadata missing for event ${eventName}`, 546: ) 547: } 548: events.push({ 549: event_type: 'ClaudeCodeInternalEvent', 550: event_data: ClaudeCodeInternalEvent.toJSON({ 551: event_id: attributes.event_id as string | undefined, 552: event_name: eventName, 553: client_timestamp: this.hrTimeToDate(log.hrTime), 554: session_id: getSessionId(), 555: additional_metadata: Buffer.from( 556: jsonStringify({ 557: transform_error: 'core_metadata attribute is missing', 558: }), 559: ).toString('base64'), 560: }), 561: }) 562: continue 563: } 564: const formatted = to1PEventFormat( 565: coreMetadata, 566: userMetadata, 567: eventMetadata, 568: ) 569: const { 570: _PROTO_skill_name, 571: _PROTO_plugin_name, 572: _PROTO_marketplace_name, 573: ...rest 574: } = formatted.additional 575: const additionalMetadata = stripProtoFields(rest) 576: events.push({ 577: event_type: 'ClaudeCodeInternalEvent', 578: event_data: ClaudeCodeInternalEvent.toJSON({ 579: event_id: attributes.event_id as string | undefined, 580: event_name: eventName, 581: client_timestamp: this.hrTimeToDate(log.hrTime), 582: device_id: attributes.user_id as string | undefined, 583: email: userMetadata?.email, 584: auth: formatted.auth, 585: ...formatted.core, 586: env: formatted.env, 587: process: formatted.process, 588: skill_name: 589: typeof _PROTO_skill_name === 'string' 590: ? _PROTO_skill_name 591: : undefined, 592: plugin_name: 593: typeof _PROTO_plugin_name === 'string' 594: ? _PROTO_plugin_name 595: : undefined, 596: marketplace_name: 597: typeof _PROTO_marketplace_name === 'string' 598: ? _PROTO_marketplace_name 599: : undefined, 600: additional_metadata: 601: Object.keys(additionalMetadata).length > 0 602: ? Buffer.from(jsonStringify(additionalMetadata)).toString( 603: 'base64', 604: ) 605: : undefined, 606: }), 607: }) 608: } 609: return { events } 610: } 611: async shutdown(): Promise<void> { 612: this.isShutdown = true 613: this.resetBackoff() 614: await this.forceFlush() 615: if (process.env.USER_TYPE === 'ant') { 616: logForDebugging('1P event logging exporter shutdown complete') 617: } 618: } 619: async forceFlush(): Promise<void> { 620: await Promise.all(this.pendingExports) 621: if (process.env.USER_TYPE === 'ant') { 622: logForDebugging('1P event logging exporter flush complete') 623: } 624: } 625: } 626: function getAxiosErrorContext(error: unknown): string { 627: if (!axios.isAxiosError(error)) { 628: return errorMessage(error) 629: } 630: const parts: string[] = [] 631: const requestId = error.response?.headers?.['request-id'] 632: if (requestId) { 633: parts.push(`request-id=${requestId}`) 634: } 635: if (error.response?.status) { 636: parts.push(`status=${error.response.status}`) 637: } 638: if (error.code) { 639: parts.push(`code=${error.code}`) 640: } 641: if (error.message) { 642: parts.push(error.message) 643: } 644: return parts.join(', ') 645: }

File: src/services/analytics/growthbook.ts

typescript 1: import { GrowthBook } from '@growthbook/growthbook' 2: import { isEqual, memoize } from 'lodash-es' 3: import { 4: getIsNonInteractiveSession, 5: getSessionTrustAccepted, 6: } from '../../bootstrap/state.js' 7: import { getGrowthBookClientKey } from '../../constants/keys.js' 8: import { 9: checkHasTrustDialogAccepted, 10: getGlobalConfig, 11: saveGlobalConfig, 12: } from '../../utils/config.js' 13: import { logForDebugging } from '../../utils/debug.js' 14: import { toError } from '../../utils/errors.js' 15: import { getAuthHeaders } from '../../utils/http.js' 16: import { logError } from '../../utils/log.js' 17: import { createSignal } from '../../utils/signal.js' 18: import { jsonStringify } from '../../utils/slowOperations.js' 19: import { 20: type GitHubActionsMetadata, 21: getUserForGrowthBook, 22: } from '../../utils/user.js' 23: import { 24: is1PEventLoggingEnabled, 25: logGrowthBookExperimentTo1P, 26: } from './firstPartyEventLogger.js' 27: export type GrowthBookUserAttributes = { 28: id: string 29: sessionId: string 30: deviceID: string 31: platform: 'win32' | 'darwin' | 'linux' 32: apiBaseUrlHost?: string 33: organizationUUID?: string 34: accountUUID?: string 35: userType?: string 36: subscriptionType?: string 37: rateLimitTier?: string 38: firstTokenTime?: number 39: email?: string 40: appVersion?: string 41: github?: GitHubActionsMetadata 42: } 43: type MalformedFeatureDefinition = { 44: value?: unknown 45: defaultValue?: unknown 46: [key: string]: unknown 47: } 48: let client: GrowthBook | null = null 49: let currentBeforeExitHandler: (() => void) | null = null 50: let currentExitHandler: (() => void) | null = null 51: let clientCreatedWithAuth = false 52: type StoredExperimentData = { 53: experimentId: string 54: variationId: number 55: inExperiment?: boolean 56: hashAttribute?: string 57: hashValue?: string 58: } 59: const experimentDataByFeature = new Map<string, StoredExperimentData>() 60: const remoteEvalFeatureValues = new Map<string, unknown>() 61: const pendingExposures = new Set<string>() 62: const loggedExposures = new Set<string>() 63: let reinitializingPromise: Promise<unknown> | null = null 64: type GrowthBookRefreshListener = () => void | Promise<void> 65: const refreshed = createSignal() 66: function callSafe(listener: GrowthBookRefreshListener): void { 67: try { 68: void Promise.resolve(listener()).catch(e => { 69: logError(e) 70: }) 71: } catch (e) { 72: logError(e) 73: } 74: } 75: export function onGrowthBookRefresh( 76: listener: GrowthBookRefreshListener, 77: ): () => void { 78: let subscribed = true 79: const unsubscribe = refreshed.subscribe(() => callSafe(listener)) 80: if (remoteEvalFeatureValues.size > 0) { 81: queueMicrotask(() => { 82: if (subscribed && remoteEvalFeatureValues.size > 0) { 83: callSafe(listener) 84: } 85: }) 86: } 87: return () => { 88: subscribed = false 89: unsubscribe() 90: } 91: } 92: let envOverrides: Record<string, unknown> | null = null 93: let envOverridesParsed = false 94: function getEnvOverrides(): Record<string, unknown> | null { 95: if (!envOverridesParsed) { 96: envOverridesParsed = true 97: if (process.env.USER_TYPE === 'ant') { 98: const raw = process.env.CLAUDE_INTERNAL_FC_OVERRIDES 99: if (raw) { 100: try { 101: envOverrides = JSON.parse(raw) as Record<string, unknown> 102: logForDebugging( 103: `GrowthBook: Using env var overrides for ${Object.keys(envOverrides!).length} features: ${Object.keys(envOverrides!).join(', ')}`, 104: ) 105: } catch { 106: logError( 107: new Error( 108: `GrowthBook: Failed to parse CLAUDE_INTERNAL_FC_OVERRIDES: ${raw}`, 109: ), 110: ) 111: } 112: } 113: } 114: } 115: return envOverrides 116: } 117: export function hasGrowthBookEnvOverride(feature: string): boolean { 118: const overrides = getEnvOverrides() 119: return overrides !== null && feature in overrides 120: } 121: function getConfigOverrides(): Record<string, unknown> | undefined { 122: if (process.env.USER_TYPE !== 'ant') return undefined 123: try { 124: return getGlobalConfig().growthBookOverrides 125: } catch { 126: return undefined 127: } 128: } 129: export function getAllGrowthBookFeatures(): Record<string, unknown> { 130: if (remoteEvalFeatureValues.size > 0) { 131: return Object.fromEntries(remoteEvalFeatureValues) 132: } 133: return getGlobalConfig().cachedGrowthBookFeatures ?? {} 134: } 135: export function getGrowthBookConfigOverrides(): Record<string, unknown> { 136: return getConfigOverrides() ?? {} 137: } 138: export function setGrowthBookConfigOverride( 139: feature: string, 140: value: unknown, 141: ): void { 142: if (process.env.USER_TYPE !== 'ant') return 143: try { 144: saveGlobalConfig(c => { 145: const current = c.growthBookOverrides ?? {} 146: if (value === undefined) { 147: if (!(feature in current)) return c 148: const { [feature]: _, ...rest } = current 149: if (Object.keys(rest).length === 0) { 150: const { growthBookOverrides: __, ...configWithout } = c 151: return configWithout 152: } 153: return { ...c, growthBookOverrides: rest } 154: } 155: if (isEqual(current[feature], value)) return c 156: return { ...c, growthBookOverrides: { ...current, [feature]: value } } 157: }) 158: refreshed.emit() 159: } catch (e) { 160: logError(e) 161: } 162: } 163: export function clearGrowthBookConfigOverrides(): void { 164: if (process.env.USER_TYPE !== 'ant') return 165: try { 166: saveGlobalConfig(c => { 167: if ( 168: !c.growthBookOverrides || 169: Object.keys(c.growthBookOverrides).length === 0 170: ) { 171: return c 172: } 173: const { growthBookOverrides: _, ...rest } = c 174: return rest 175: }) 176: refreshed.emit() 177: } catch (e) { 178: logError(e) 179: } 180: } 181: function logExposureForFeature(feature: string): void { 182: if (loggedExposures.has(feature)) { 183: return 184: } 185: const expData = experimentDataByFeature.get(feature) 186: if (expData) { 187: loggedExposures.add(feature) 188: logGrowthBookExperimentTo1P({ 189: experimentId: expData.experimentId, 190: variationId: expData.variationId, 191: userAttributes: getUserAttributes(), 192: experimentMetadata: { 193: feature_id: feature, 194: }, 195: }) 196: } 197: } 198: async function processRemoteEvalPayload( 199: gbClient: GrowthBook, 200: ): Promise<boolean> { 201: const payload = gbClient.getPayload() 202: if (!payload?.features || Object.keys(payload.features).length === 0) { 203: return false 204: } 205: experimentDataByFeature.clear() 206: const transformedFeatures: Record<string, MalformedFeatureDefinition> = {} 207: for (const [key, feature] of Object.entries(payload.features)) { 208: const f = feature as MalformedFeatureDefinition 209: if ('value' in f && !('defaultValue' in f)) { 210: transformedFeatures[key] = { 211: ...f, 212: defaultValue: f.value, 213: } 214: } else { 215: transformedFeatures[key] = f 216: } 217: if (f.source === 'experiment' && f.experimentResult) { 218: const expResult = f.experimentResult as { 219: variationId?: number 220: } 221: const exp = f.experiment as { key?: string } | undefined 222: if (exp?.key && expResult.variationId !== undefined) { 223: experimentDataByFeature.set(key, { 224: experimentId: exp.key, 225: variationId: expResult.variationId, 226: }) 227: } 228: } 229: } 230: await gbClient.setPayload({ 231: ...payload, 232: features: transformedFeatures, 233: }) 234: remoteEvalFeatureValues.clear() 235: for (const [key, feature] of Object.entries(transformedFeatures)) { 236: const v = 'value' in feature ? feature.value : feature.defaultValue 237: if (v !== undefined) { 238: remoteEvalFeatureValues.set(key, v) 239: } 240: } 241: return true 242: } 243: function syncRemoteEvalToDisk(): void { 244: const fresh = Object.fromEntries(remoteEvalFeatureValues) 245: const config = getGlobalConfig() 246: if (isEqual(config.cachedGrowthBookFeatures, fresh)) { 247: return 248: } 249: saveGlobalConfig(current => ({ 250: ...current, 251: cachedGrowthBookFeatures: fresh, 252: })) 253: } 254: function isGrowthBookEnabled(): boolean { 255: return is1PEventLoggingEnabled() 256: } 257: export function getApiBaseUrlHost(): string | undefined { 258: const baseUrl = process.env.ANTHROPIC_BASE_URL 259: if (!baseUrl) return undefined 260: try { 261: const host = new URL(baseUrl).host 262: if (host === 'api.anthropic.com') return undefined 263: return host 264: } catch { 265: return undefined 266: } 267: } 268: function getUserAttributes(): GrowthBookUserAttributes { 269: const user = getUserForGrowthBook() 270: let email = user.email 271: if (!email && process.env.USER_TYPE === 'ant') { 272: email = getGlobalConfig().oauthAccount?.emailAddress 273: } 274: const apiBaseUrlHost = getApiBaseUrlHost() 275: const attributes = { 276: id: user.deviceId, 277: sessionId: user.sessionId, 278: deviceID: user.deviceId, 279: platform: user.platform, 280: ...(apiBaseUrlHost && { apiBaseUrlHost }), 281: ...(user.organizationUuid && { organizationUUID: user.organizationUuid }), 282: ...(user.accountUuid && { accountUUID: user.accountUuid }), 283: ...(user.userType && { userType: user.userType }), 284: ...(user.subscriptionType && { subscriptionType: user.subscriptionType }), 285: ...(user.rateLimitTier && { rateLimitTier: user.rateLimitTier }), 286: ...(user.firstTokenTime && { firstTokenTime: user.firstTokenTime }), 287: ...(email && { email }), 288: ...(user.appVersion && { appVersion: user.appVersion }), 289: ...(user.githubActionsMetadata && { 290: githubActionsMetadata: user.githubActionsMetadata, 291: }), 292: } 293: return attributes 294: } 295: const getGrowthBookClient = memoize( 296: (): { client: GrowthBook; initialized: Promise<void> } | null => { 297: if (!isGrowthBookEnabled()) { 298: return null 299: } 300: const attributes = getUserAttributes() 301: const clientKey = getGrowthBookClientKey() 302: if (process.env.USER_TYPE === 'ant') { 303: logForDebugging( 304: `GrowthBook: Creating client with clientKey=${clientKey}, attributes: ${jsonStringify(attributes)}`, 305: ) 306: } 307: const baseUrl = 308: process.env.USER_TYPE === 'ant' 309: ? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/' 310: : 'https://api.anthropic.com/' 311: const hasTrust = 312: checkHasTrustDialogAccepted() || 313: getSessionTrustAccepted() || 314: getIsNonInteractiveSession() 315: const authHeaders = hasTrust 316: ? getAuthHeaders() 317: : { headers: {}, error: 'trust not established' } 318: const hasAuth = !authHeaders.error 319: clientCreatedWithAuth = hasAuth 320: const thisClient = new GrowthBook({ 321: apiHost: baseUrl, 322: clientKey, 323: attributes, 324: remoteEval: true, 325: cacheKeyAttributes: ['id', 'organizationUUID'], 326: ...(authHeaders.error 327: ? {} 328: : { apiHostRequestHeaders: authHeaders.headers }), 329: ...(process.env.USER_TYPE === 'ant' 330: ? { 331: log: (msg: string, ctx: Record<string, unknown>) => { 332: logForDebugging(`GrowthBook: ${msg} ${jsonStringify(ctx)}`) 333: }, 334: } 335: : {}), 336: }) 337: client = thisClient 338: if (!hasAuth) { 339: return { client: thisClient, initialized: Promise.resolve() } 340: } 341: const initialized = thisClient 342: .init({ timeout: 5000 }) 343: .then(async result => { 344: if (client !== thisClient) { 345: if (process.env.USER_TYPE === 'ant') { 346: logForDebugging( 347: 'GrowthBook: Skipping init callback for replaced client', 348: ) 349: } 350: return 351: } 352: if (process.env.USER_TYPE === 'ant') { 353: logForDebugging( 354: `GrowthBook initialized successfully, source: ${result.source}, success: ${result.success}`, 355: ) 356: } 357: const hadFeatures = await processRemoteEvalPayload(thisClient) 358: if (client !== thisClient) return 359: if (hadFeatures) { 360: for (const feature of pendingExposures) { 361: logExposureForFeature(feature) 362: } 363: pendingExposures.clear() 364: syncRemoteEvalToDisk() 365: refreshed.emit() 366: } 367: if (process.env.USER_TYPE === 'ant') { 368: const features = thisClient.getFeatures() 369: if (features) { 370: const featureKeys = Object.keys(features) 371: logForDebugging( 372: `GrowthBook loaded ${featureKeys.length} features: ${featureKeys.slice(0, 10).join(', ')}${featureKeys.length > 10 ? '...' : ''}`, 373: ) 374: } 375: } 376: }) 377: .catch(error => { 378: if (process.env.USER_TYPE === 'ant') { 379: logError(toError(error)) 380: } 381: }) 382: currentBeforeExitHandler = () => client?.destroy() 383: currentExitHandler = () => client?.destroy() 384: process.on('beforeExit', currentBeforeExitHandler) 385: process.on('exit', currentExitHandler) 386: return { client: thisClient, initialized } 387: }, 388: ) 389: export const initializeGrowthBook = memoize( 390: async (): Promise<GrowthBook | null> => { 391: let clientWrapper = getGrowthBookClient() 392: if (!clientWrapper) { 393: return null 394: } 395: if (!clientCreatedWithAuth) { 396: const hasTrust = 397: checkHasTrustDialogAccepted() || 398: getSessionTrustAccepted() || 399: getIsNonInteractiveSession() 400: if (hasTrust) { 401: const currentAuth = getAuthHeaders() 402: if (!currentAuth.error) { 403: if (process.env.USER_TYPE === 'ant') { 404: logForDebugging( 405: 'GrowthBook: Auth became available after client creation, reinitializing', 406: ) 407: } 408: resetGrowthBook() 409: clientWrapper = getGrowthBookClient() 410: if (!clientWrapper) { 411: return null 412: } 413: } 414: } 415: } 416: await clientWrapper.initialized 417: setupPeriodicGrowthBookRefresh() 418: return clientWrapper.client 419: }, 420: ) 421: async function getFeatureValueInternal<T>( 422: feature: string, 423: defaultValue: T, 424: logExposure: boolean, 425: ): Promise<T> { 426: const overrides = getEnvOverrides() 427: if (overrides && feature in overrides) { 428: return overrides[feature] as T 429: } 430: const configOverrides = getConfigOverrides() 431: if (configOverrides && feature in configOverrides) { 432: return configOverrides[feature] as T 433: } 434: if (!isGrowthBookEnabled()) { 435: return defaultValue 436: } 437: const growthBookClient = await initializeGrowthBook() 438: if (!growthBookClient) { 439: return defaultValue 440: } 441: let result: T 442: if (remoteEvalFeatureValues.has(feature)) { 443: result = remoteEvalFeatureValues.get(feature) as T 444: } else { 445: result = growthBookClient.getFeatureValue(feature, defaultValue) as T 446: } 447: if (logExposure) { 448: logExposureForFeature(feature) 449: } 450: if (process.env.USER_TYPE === 'ant') { 451: logForDebugging( 452: `GrowthBook: getFeatureValue("${feature}") = ${jsonStringify(result)}`, 453: ) 454: } 455: return result 456: } 457: export async function getFeatureValue_DEPRECATED<T>( 458: feature: string, 459: defaultValue: T, 460: ): Promise<T> { 461: return getFeatureValueInternal(feature, defaultValue, true) 462: } 463: export function getFeatureValue_CACHED_MAY_BE_STALE<T>( 464: feature: string, 465: defaultValue: T, 466: ): T { 467: const overrides = getEnvOverrides() 468: if (overrides && feature in overrides) { 469: return overrides[feature] as T 470: } 471: const configOverrides = getConfigOverrides() 472: if (configOverrides && feature in configOverrides) { 473: return configOverrides[feature] as T 474: } 475: if (!isGrowthBookEnabled()) { 476: return defaultValue 477: } 478: if (experimentDataByFeature.has(feature)) { 479: logExposureForFeature(feature) 480: } else { 481: pendingExposures.add(feature) 482: } 483: if (remoteEvalFeatureValues.has(feature)) { 484: return remoteEvalFeatureValues.get(feature) as T 485: } 486: try { 487: const cached = getGlobalConfig().cachedGrowthBookFeatures?.[feature] 488: return cached !== undefined ? (cached as T) : defaultValue 489: } catch { 490: return defaultValue 491: } 492: } 493: export function getFeatureValue_CACHED_WITH_REFRESH<T>( 494: feature: string, 495: defaultValue: T, 496: _refreshIntervalMs: number, 497: ): T { 498: return getFeatureValue_CACHED_MAY_BE_STALE(feature, defaultValue) 499: } 500: export function checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 501: gate: string, 502: ): boolean { 503: const overrides = getEnvOverrides() 504: if (overrides && gate in overrides) { 505: return Boolean(overrides[gate]) 506: } 507: const configOverrides = getConfigOverrides() 508: if (configOverrides && gate in configOverrides) { 509: return Boolean(configOverrides[gate]) 510: } 511: if (!isGrowthBookEnabled()) { 512: return false 513: } 514: if (experimentDataByFeature.has(gate)) { 515: logExposureForFeature(gate) 516: } else { 517: pendingExposures.add(gate) 518: } 519: const config = getGlobalConfig() 520: const gbCached = config.cachedGrowthBookFeatures?.[gate] 521: if (gbCached !== undefined) { 522: return Boolean(gbCached) 523: } 524: return config.cachedStatsigGates?.[gate] ?? false 525: } 526: export async function checkSecurityRestrictionGate( 527: gate: string, 528: ): Promise<boolean> { 529: const overrides = getEnvOverrides() 530: if (overrides && gate in overrides) { 531: return Boolean(overrides[gate]) 532: } 533: const configOverrides = getConfigOverrides() 534: if (configOverrides && gate in configOverrides) { 535: return Boolean(configOverrides[gate]) 536: } 537: if (!isGrowthBookEnabled()) { 538: return false 539: } 540: if (reinitializingPromise) { 541: await reinitializingPromise 542: } 543: const config = getGlobalConfig() 544: const statsigCached = config.cachedStatsigGates?.[gate] 545: if (statsigCached !== undefined) { 546: return Boolean(statsigCached) 547: } 548: const gbCached = config.cachedGrowthBookFeatures?.[gate] 549: if (gbCached !== undefined) { 550: return Boolean(gbCached) 551: } 552: return false 553: } 554: export async function checkGate_CACHED_OR_BLOCKING( 555: gate: string, 556: ): Promise<boolean> { 557: const overrides = getEnvOverrides() 558: if (overrides && gate in overrides) { 559: return Boolean(overrides[gate]) 560: } 561: const configOverrides = getConfigOverrides() 562: if (configOverrides && gate in configOverrides) { 563: return Boolean(configOverrides[gate]) 564: } 565: if (!isGrowthBookEnabled()) { 566: return false 567: } 568: const cached = getGlobalConfig().cachedGrowthBookFeatures?.[gate] 569: if (cached === true) { 570: if (experimentDataByFeature.has(gate)) { 571: logExposureForFeature(gate) 572: } else { 573: pendingExposures.add(gate) 574: } 575: return true 576: } 577: return getFeatureValueInternal(gate, false, true) 578: } 579: export function refreshGrowthBookAfterAuthChange(): void { 580: if (!isGrowthBookEnabled()) { 581: return 582: } 583: try { 584: resetGrowthBook() 585: refreshed.emit() 586: reinitializingPromise = initializeGrowthBook() 587: .catch(error => { 588: logError(toError(error)) 589: return null 590: }) 591: .finally(() => { 592: reinitializingPromise = null 593: }) 594: } catch (error) { 595: if (process.env.NODE_ENV === 'development') { 596: throw error 597: } 598: logError(toError(error)) 599: } 600: } 601: export function resetGrowthBook(): void { 602: stopPeriodicGrowthBookRefresh() 603: if (currentBeforeExitHandler) { 604: process.off('beforeExit', currentBeforeExitHandler) 605: currentBeforeExitHandler = null 606: } 607: if (currentExitHandler) { 608: process.off('exit', currentExitHandler) 609: currentExitHandler = null 610: } 611: client?.destroy() 612: client = null 613: clientCreatedWithAuth = false 614: reinitializingPromise = null 615: experimentDataByFeature.clear() 616: pendingExposures.clear() 617: loggedExposures.clear() 618: remoteEvalFeatureValues.clear() 619: getGrowthBookClient.cache?.clear?.() 620: initializeGrowthBook.cache?.clear?.() 621: envOverrides = null 622: envOverridesParsed = false 623: } 624: const GROWTHBOOK_REFRESH_INTERVAL_MS = 625: process.env.USER_TYPE !== 'ant' 626: ? 6 * 60 * 60 * 1000 627: : 20 * 60 * 1000 628: let refreshInterval: ReturnType<typeof setInterval> | null = null 629: let beforeExitListener: (() => void) | null = null 630: export async function refreshGrowthBookFeatures(): Promise<void> { 631: if (!isGrowthBookEnabled()) { 632: return 633: } 634: try { 635: const growthBookClient = await initializeGrowthBook() 636: if (!growthBookClient) { 637: return 638: } 639: await growthBookClient.refreshFeatures() 640: if (growthBookClient !== client) { 641: if (process.env.USER_TYPE === 'ant') { 642: logForDebugging( 643: 'GrowthBook: Skipping refresh processing for replaced client', 644: ) 645: } 646: return 647: } 648: const hadFeatures = await processRemoteEvalPayload(growthBookClient) 649: if (growthBookClient !== client) return 650: if (process.env.USER_TYPE === 'ant') { 651: logForDebugging('GrowthBook: Light refresh completed') 652: } 653: if (hadFeatures) { 654: syncRemoteEvalToDisk() 655: refreshed.emit() 656: } 657: } catch (error) { 658: if (process.env.NODE_ENV === 'development') { 659: throw error 660: } 661: logError(toError(error)) 662: } 663: } 664: export function setupPeriodicGrowthBookRefresh(): void { 665: if (!isGrowthBookEnabled()) { 666: return 667: } 668: if (refreshInterval) { 669: clearInterval(refreshInterval) 670: } 671: refreshInterval = setInterval(() => { 672: void refreshGrowthBookFeatures() 673: }, GROWTHBOOK_REFRESH_INTERVAL_MS) 674: refreshInterval.unref?.() 675: if (!beforeExitListener) { 676: beforeExitListener = () => { 677: stopPeriodicGrowthBookRefresh() 678: } 679: process.once('beforeExit', beforeExitListener) 680: } 681: } 682: export function stopPeriodicGrowthBookRefresh(): void { 683: if (refreshInterval) { 684: clearInterval(refreshInterval) 685: refreshInterval = null 686: } 687: if (beforeExitListener) { 688: process.removeListener('beforeExit', beforeExitListener) 689: beforeExitListener = null 690: } 691: } 692: export async function getDynamicConfig_BLOCKS_ON_INIT<T>( 693: configName: string, 694: defaultValue: T, 695: ): Promise<T> { 696: return getFeatureValue_DEPRECATED(configName, defaultValue) 697: } 698: export function getDynamicConfig_CACHED_MAY_BE_STALE<T>( 699: configName: string, 700: defaultValue: T, 701: ): T { 702: return getFeatureValue_CACHED_MAY_BE_STALE(configName, defaultValue) 703: }

File: src/services/analytics/index.ts

typescript 1: export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never 2: export type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED = never 3: export function stripProtoFields<V>( 4: metadata: Record<string, V>, 5: ): Record<string, V> { 6: let result: Record<string, V> | undefined 7: for (const key in metadata) { 8: if (key.startsWith('_PROTO_')) { 9: if (result === undefined) { 10: result = { ...metadata } 11: } 12: delete result[key] 13: } 14: } 15: return result ?? metadata 16: } 17: type LogEventMetadata = { [key: string]: boolean | number | undefined } 18: type QueuedEvent = { 19: eventName: string 20: metadata: LogEventMetadata 21: async: boolean 22: } 23: export type AnalyticsSink = { 24: logEvent: (eventName: string, metadata: LogEventMetadata) => void 25: logEventAsync: ( 26: eventName: string, 27: metadata: LogEventMetadata, 28: ) => Promise<void> 29: } 30: const eventQueue: QueuedEvent[] = [] 31: let sink: AnalyticsSink | null = null 32: export function attachAnalyticsSink(newSink: AnalyticsSink): void { 33: if (sink !== null) { 34: return 35: } 36: sink = newSink 37: if (eventQueue.length > 0) { 38: const queuedEvents = [...eventQueue] 39: eventQueue.length = 0 40: if (process.env.USER_TYPE === 'ant') { 41: sink.logEvent('analytics_sink_attached', { 42: queued_event_count: queuedEvents.length, 43: }) 44: } 45: queueMicrotask(() => { 46: for (const event of queuedEvents) { 47: if (event.async) { 48: void sink!.logEventAsync(event.eventName, event.metadata) 49: } else { 50: sink!.logEvent(event.eventName, event.metadata) 51: } 52: } 53: }) 54: } 55: } 56: export function logEvent( 57: eventName: string, 58: metadata: LogEventMetadata, 59: ): void { 60: if (sink === null) { 61: eventQueue.push({ eventName, metadata, async: false }) 62: return 63: } 64: sink.logEvent(eventName, metadata) 65: } 66: export async function logEventAsync( 67: eventName: string, 68: metadata: LogEventMetadata, 69: ): Promise<void> { 70: if (sink === null) { 71: eventQueue.push({ eventName, metadata, async: true }) 72: return 73: } 74: await sink.logEventAsync(eventName, metadata) 75: } 76: export function _resetForTesting(): void { 77: sink = null 78: eventQueue.length = 0 79: }

File: src/services/analytics/metadata.ts

typescript 1: import { extname } from 'path' 2: import memoize from 'lodash-es/memoize.js' 3: import { env, getHostPlatformForAnalytics } from '../../utils/env.js' 4: import { envDynamic } from '../../utils/envDynamic.js' 5: import { getModelBetas } from '../../utils/betas.js' 6: import { getMainLoopModel } from '../../utils/model/model.js' 7: import { 8: getSessionId, 9: getIsInteractive, 10: getKairosActive, 11: getClientType, 12: getParentSessionId as getParentSessionIdFromState, 13: } from '../../bootstrap/state.js' 14: import { isEnvTruthy } from '../../utils/envUtils.js' 15: import { isOfficialMcpUrl } from '../mcp/officialRegistry.js' 16: import { isClaudeAISubscriber, getSubscriptionType } from '../../utils/auth.js' 17: import { getRepoRemoteHash } from '../../utils/git.js' 18: import { 19: getWslVersion, 20: getLinuxDistroInfo, 21: detectVcs, 22: } from '../../utils/platform.js' 23: import type { CoreUserData } from 'src/utils/user.js' 24: import { getAgentContext } from '../../utils/agentContext.js' 25: import type { EnvironmentMetadata } from '../../types/generated/events_mono/claude_code/v1/claude_code_internal_event.js' 26: import type { PublicApiAuth } from '../../types/generated/events_mono/common/v1/auth.js' 27: import { jsonStringify } from '../../utils/slowOperations.js' 28: import { 29: getAgentId, 30: getParentSessionId as getTeammateParentSessionId, 31: getTeamName, 32: isTeammate, 33: } from '../../utils/teammate.js' 34: import { feature } from 'bun:bundle' 35: export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = never 36: export function sanitizeToolNameForAnalytics( 37: toolName: string, 38: ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { 39: if (toolName.startsWith('mcp__')) { 40: return 'mcp_tool' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 41: } 42: return toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 43: } 44: export function isToolDetailsLoggingEnabled(): boolean { 45: return isEnvTruthy(process.env.OTEL_LOG_TOOL_DETAILS) 46: } 47: export function isAnalyticsToolDetailsLoggingEnabled( 48: mcpServerType: string | undefined, 49: mcpServerBaseUrl: string | undefined, 50: ): boolean { 51: if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') { 52: return true 53: } 54: if (mcpServerType === 'claudeai-proxy') { 55: return true 56: } 57: if (mcpServerBaseUrl && isOfficialMcpUrl(mcpServerBaseUrl)) { 58: return true 59: } 60: return false 61: } 62: const BUILTIN_MCP_SERVER_NAMES: ReadonlySet<string> = new Set( 63: feature('CHICAGO_MCP') 64: ? [ 65: ( 66: require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') 67: ).COMPUTER_USE_MCP_SERVER_NAME, 68: ] 69: : [], 70: ) 71: export function mcpToolDetailsForAnalytics( 72: toolName: string, 73: mcpServerType: string | undefined, 74: mcpServerBaseUrl: string | undefined, 75: ): { 76: mcpServerName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 77: mcpToolName?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 78: } { 79: const details = extractMcpToolDetails(toolName) 80: if (!details) { 81: return {} 82: } 83: if ( 84: !BUILTIN_MCP_SERVER_NAMES.has(details.serverName) && 85: !isAnalyticsToolDetailsLoggingEnabled(mcpServerType, mcpServerBaseUrl) 86: ) { 87: return {} 88: } 89: return { 90: mcpServerName: details.serverName, 91: mcpToolName: details.mcpToolName, 92: } 93: } 94: export function extractMcpToolDetails(toolName: string): 95: | { 96: serverName: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 97: mcpToolName: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 98: } 99: | undefined { 100: if (!toolName.startsWith('mcp__')) { 101: return undefined 102: } 103: const parts = toolName.split('__') 104: if (parts.length < 3) { 105: return undefined 106: } 107: const serverName = parts[1] 108: const mcpToolName = parts.slice(2).join('__') 109: if (!serverName || !mcpToolName) { 110: return undefined 111: } 112: return { 113: serverName: 114: serverName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 115: mcpToolName: 116: mcpToolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 117: } 118: } 119: export function extractSkillName( 120: toolName: string, 121: input: unknown, 122: ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined { 123: if (toolName !== 'Skill') { 124: return undefined 125: } 126: if ( 127: typeof input === 'object' && 128: input !== null && 129: 'skill' in input && 130: typeof (input as { skill: unknown }).skill === 'string' 131: ) { 132: return (input as { skill: string }) 133: .skill as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 134: } 135: return undefined 136: } 137: const TOOL_INPUT_STRING_TRUNCATE_AT = 512 138: const TOOL_INPUT_STRING_TRUNCATE_TO = 128 139: const TOOL_INPUT_MAX_JSON_CHARS = 4 * 1024 140: const TOOL_INPUT_MAX_COLLECTION_ITEMS = 20 141: const TOOL_INPUT_MAX_DEPTH = 2 142: function truncateToolInputValue(value: unknown, depth = 0): unknown { 143: if (typeof value === 'string') { 144: if (value.length > TOOL_INPUT_STRING_TRUNCATE_AT) { 145: return `${value.slice(0, TOOL_INPUT_STRING_TRUNCATE_TO)}…[${value.length} chars]` 146: } 147: return value 148: } 149: if ( 150: typeof value === 'number' || 151: typeof value === 'boolean' || 152: value === null || 153: value === undefined 154: ) { 155: return value 156: } 157: if (depth >= TOOL_INPUT_MAX_DEPTH) { 158: return '<nested>' 159: } 160: if (Array.isArray(value)) { 161: const mapped = value 162: .slice(0, TOOL_INPUT_MAX_COLLECTION_ITEMS) 163: .map(v => truncateToolInputValue(v, depth + 1)) 164: if (value.length > TOOL_INPUT_MAX_COLLECTION_ITEMS) { 165: mapped.push(`…[${value.length} items]`) 166: } 167: return mapped 168: } 169: if (typeof value === 'object') { 170: const entries = Object.entries(value as Record<string, unknown>) 171: .filter(([k]) => !k.startsWith('_')) 172: const mapped = entries 173: .slice(0, TOOL_INPUT_MAX_COLLECTION_ITEMS) 174: .map(([k, v]) => [k, truncateToolInputValue(v, depth + 1)]) 175: if (entries.length > TOOL_INPUT_MAX_COLLECTION_ITEMS) { 176: mapped.push(['…', `${entries.length} keys`]) 177: } 178: return Object.fromEntries(mapped) 179: } 180: return String(value) 181: } 182: export function extractToolInputForTelemetry( 183: input: unknown, 184: ): string | undefined { 185: if (!isToolDetailsLoggingEnabled()) { 186: return undefined 187: } 188: const truncated = truncateToolInputValue(input) 189: let json = jsonStringify(truncated) 190: if (json.length > TOOL_INPUT_MAX_JSON_CHARS) { 191: json = json.slice(0, TOOL_INPUT_MAX_JSON_CHARS) + '…[truncated]' 192: } 193: return json 194: } 195: const MAX_FILE_EXTENSION_LENGTH = 10 196: export function getFileExtensionForAnalytics( 197: filePath: string, 198: ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined { 199: const ext = extname(filePath).toLowerCase() 200: if (!ext || ext === '.') { 201: return undefined 202: } 203: const extension = ext.slice(1) 204: if (extension.length > MAX_FILE_EXTENSION_LENGTH) { 205: return 'other' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 206: } 207: return extension as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 208: } 209: const FILE_COMMANDS = new Set([ 210: 'rm', 211: 'mv', 212: 'cp', 213: 'touch', 214: 'mkdir', 215: 'chmod', 216: 'chown', 217: 'cat', 218: 'head', 219: 'tail', 220: 'sort', 221: 'stat', 222: 'diff', 223: 'wc', 224: 'grep', 225: 'rg', 226: 'sed', 227: ]) 228: const COMPOUND_OPERATOR_REGEX = /\s*(?:&&|\|\||[;|])\s*/ 229: const WHITESPACE_REGEX = /\s+/ 230: export function getFileExtensionsFromBashCommand( 231: command: string, 232: simulatedSedEditFilePath?: string, 233: ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS | undefined { 234: if (!command.includes('.') && !simulatedSedEditFilePath) return undefined 235: let result: string | undefined 236: const seen = new Set<string>() 237: if (simulatedSedEditFilePath) { 238: const ext = getFileExtensionForAnalytics(simulatedSedEditFilePath) 239: if (ext) { 240: seen.add(ext) 241: result = ext 242: } 243: } 244: for (const subcmd of command.split(COMPOUND_OPERATOR_REGEX)) { 245: if (!subcmd) continue 246: const tokens = subcmd.split(WHITESPACE_REGEX) 247: if (tokens.length < 2) continue 248: const firstToken = tokens[0]! 249: const slashIdx = firstToken.lastIndexOf('/') 250: const baseCmd = slashIdx >= 0 ? firstToken.slice(slashIdx + 1) : firstToken 251: if (!FILE_COMMANDS.has(baseCmd)) continue 252: for (let i = 1; i < tokens.length; i++) { 253: const arg = tokens[i]! 254: if (arg.charCodeAt(0) === 45 ) continue 255: const ext = getFileExtensionForAnalytics(arg) 256: if (ext && !seen.has(ext)) { 257: seen.add(ext) 258: result = result ? result + ',' + ext : ext 259: } 260: } 261: } 262: if (!result) return undefined 263: return result as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 264: } 265: export type EnvContext = { 266: platform: string 267: platformRaw: string 268: arch: string 269: nodeVersion: string 270: terminal: string | null 271: packageManagers: string 272: runtimes: string 273: isRunningWithBun: boolean 274: isCi: boolean 275: isClaubbit: boolean 276: isClaudeCodeRemote: boolean 277: isLocalAgentMode: boolean 278: isConductor: boolean 279: remoteEnvironmentType?: string 280: coworkerType?: string 281: claudeCodeContainerId?: string 282: claudeCodeRemoteSessionId?: string 283: tags?: string 284: isGithubAction: boolean 285: isClaudeCodeAction: boolean 286: isClaudeAiAuth: boolean 287: version: string 288: versionBase?: string 289: buildTime: string 290: deploymentEnvironment: string 291: githubEventName?: string 292: githubActionsRunnerEnvironment?: string 293: githubActionsRunnerOs?: string 294: githubActionRef?: string 295: wslVersion?: string 296: linuxDistroId?: string 297: linuxDistroVersion?: string 298: linuxKernel?: string 299: vcs?: string 300: } 301: export type ProcessMetrics = { 302: uptime: number 303: rss: number 304: heapTotal: number 305: heapUsed: number 306: external: number 307: arrayBuffers: number 308: constrainedMemory: number | undefined 309: cpuUsage: NodeJS.CpuUsage 310: cpuPercent: number | undefined 311: } 312: export type EventMetadata = { 313: model: string 314: sessionId: string 315: userType: string 316: betas?: string 317: envContext: EnvContext 318: entrypoint?: string 319: agentSdkVersion?: string 320: isInteractive: string 321: clientType: string 322: processMetrics?: ProcessMetrics 323: sweBenchRunId: string 324: sweBenchInstanceId: string 325: sweBenchTaskId: string 326: agentId?: string 327: parentSessionId?: string 328: agentType?: 'teammate' | 'subagent' | 'standalone' 329: teamName?: string 330: subscriptionType?: string 331: rh?: string 332: kairosActive?: true 333: skillMode?: 'discovery' | 'coach' | 'discovery_and_coach' 334: observerMode?: 'backseat' | 'skillcoach' | 'both' 335: } 336: export type EnrichMetadataOptions = { 337: model?: unknown 338: betas?: unknown 339: additionalMetadata?: Record<string, unknown> 340: } 341: function getAgentIdentification(): { 342: agentId?: string 343: parentSessionId?: string 344: agentType?: 'teammate' | 'subagent' | 'standalone' 345: teamName?: string 346: } { 347: const agentContext = getAgentContext() 348: if (agentContext) { 349: const result: ReturnType<typeof getAgentIdentification> = { 350: agentId: agentContext.agentId, 351: parentSessionId: agentContext.parentSessionId, 352: agentType: agentContext.agentType, 353: } 354: if (agentContext.agentType === 'teammate') { 355: result.teamName = agentContext.teamName 356: } 357: return result 358: } 359: const agentId = getAgentId() 360: const parentSessionId = getTeammateParentSessionId() 361: const teamName = getTeamName() 362: const isSwarmAgent = isTeammate() 363: const agentType = isSwarmAgent 364: ? ('teammate' as const) 365: : agentId 366: ? ('standalone' as const) 367: : undefined 368: if (agentId || agentType || parentSessionId || teamName) { 369: return { 370: ...(agentId ? { agentId } : {}), 371: ...(agentType ? { agentType } : {}), 372: ...(parentSessionId ? { parentSessionId } : {}), 373: ...(teamName ? { teamName } : {}), 374: } 375: } 376: const stateParentSessionId = getParentSessionIdFromState() 377: if (stateParentSessionId) { 378: return { parentSessionId: stateParentSessionId } 379: } 380: return {} 381: } 382: const getVersionBase = memoize((): string | undefined => { 383: const match = MACRO.VERSION.match(/^\d+\.\d+\.\d+(?:-[a-z]+)?/) 384: return match ? match[0] : undefined 385: }) 386: const buildEnvContext = memoize(async (): Promise<EnvContext> => { 387: const [packageManagers, runtimes, linuxDistroInfo, vcs] = await Promise.all([ 388: env.getPackageManagers(), 389: env.getRuntimes(), 390: getLinuxDistroInfo(), 391: detectVcs(), 392: ]) 393: return { 394: platform: getHostPlatformForAnalytics(), 395: platformRaw: process.env.CLAUDE_CODE_HOST_PLATFORM || process.platform, 396: arch: env.arch, 397: nodeVersion: env.nodeVersion, 398: terminal: envDynamic.terminal, 399: packageManagers: packageManagers.join(','), 400: runtimes: runtimes.join(','), 401: isRunningWithBun: env.isRunningWithBun(), 402: isCi: isEnvTruthy(process.env.CI), 403: isClaubbit: isEnvTruthy(process.env.CLAUBBIT), 404: isClaudeCodeRemote: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE), 405: isLocalAgentMode: process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent', 406: isConductor: env.isConductor(), 407: ...(process.env.CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE && { 408: remoteEnvironmentType: process.env.CLAUDE_CODE_REMOTE_ENVIRONMENT_TYPE, 409: }), 410: ...(feature('COWORKER_TYPE_TELEMETRY') 411: ? process.env.CLAUDE_CODE_COWORKER_TYPE 412: ? { coworkerType: process.env.CLAUDE_CODE_COWORKER_TYPE } 413: : {} 414: : {}), 415: ...(process.env.CLAUDE_CODE_CONTAINER_ID && { 416: claudeCodeContainerId: process.env.CLAUDE_CODE_CONTAINER_ID, 417: }), 418: ...(process.env.CLAUDE_CODE_REMOTE_SESSION_ID && { 419: claudeCodeRemoteSessionId: process.env.CLAUDE_CODE_REMOTE_SESSION_ID, 420: }), 421: ...(process.env.CLAUDE_CODE_TAGS && { 422: tags: process.env.CLAUDE_CODE_TAGS, 423: }), 424: isGithubAction: isEnvTruthy(process.env.GITHUB_ACTIONS), 425: isClaudeCodeAction: isEnvTruthy(process.env.CLAUDE_CODE_ACTION), 426: isClaudeAiAuth: isClaudeAISubscriber(), 427: version: MACRO.VERSION, 428: versionBase: getVersionBase(), 429: buildTime: MACRO.BUILD_TIME, 430: deploymentEnvironment: env.detectDeploymentEnvironment(), 431: ...(isEnvTruthy(process.env.GITHUB_ACTIONS) && { 432: githubEventName: process.env.GITHUB_EVENT_NAME, 433: githubActionsRunnerEnvironment: process.env.RUNNER_ENVIRONMENT, 434: githubActionsRunnerOs: process.env.RUNNER_OS, 435: githubActionRef: process.env.GITHUB_ACTION_PATH?.includes( 436: 'claude-code-action/', 437: ) 438: ? process.env.GITHUB_ACTION_PATH.split('claude-code-action/')[1] 439: : undefined, 440: }), 441: ...(getWslVersion() && { wslVersion: getWslVersion() }), 442: ...(linuxDistroInfo ?? {}), 443: ...(vcs.length > 0 ? { vcs: vcs.join(',') } : {}), 444: } 445: }) 446: let prevCpuUsage: NodeJS.CpuUsage | null = null 447: let prevWallTimeMs: number | null = null 448: function buildProcessMetrics(): ProcessMetrics | undefined { 449: try { 450: const mem = process.memoryUsage() 451: const cpu = process.cpuUsage() 452: const now = Date.now() 453: let cpuPercent: number | undefined 454: if (prevCpuUsage && prevWallTimeMs) { 455: const wallDeltaMs = now - prevWallTimeMs 456: if (wallDeltaMs > 0) { 457: const userDeltaUs = cpu.user - prevCpuUsage.user 458: const systemDeltaUs = cpu.system - prevCpuUsage.system 459: cpuPercent = 460: ((userDeltaUs + systemDeltaUs) / (wallDeltaMs * 1000)) * 100 461: } 462: } 463: prevCpuUsage = cpu 464: prevWallTimeMs = now 465: return { 466: uptime: process.uptime(), 467: rss: mem.rss, 468: heapTotal: mem.heapTotal, 469: heapUsed: mem.heapUsed, 470: external: mem.external, 471: arrayBuffers: mem.arrayBuffers, 472: constrainedMemory: process.constrainedMemory(), 473: cpuUsage: cpu, 474: cpuPercent, 475: } 476: } catch { 477: return undefined 478: } 479: } 480: export async function getEventMetadata( 481: options: EnrichMetadataOptions = {}, 482: ): Promise<EventMetadata> { 483: const model = options.model ? String(options.model) : getMainLoopModel() 484: const betas = 485: typeof options.betas === 'string' 486: ? options.betas 487: : getModelBetas(model).join(',') 488: const [envContext, repoRemoteHash] = await Promise.all([ 489: buildEnvContext(), 490: getRepoRemoteHash(), 491: ]) 492: const processMetrics = buildProcessMetrics() 493: const metadata: EventMetadata = { 494: model, 495: sessionId: getSessionId(), 496: userType: process.env.USER_TYPE || '', 497: ...(betas.length > 0 ? { betas: betas } : {}), 498: envContext, 499: ...(process.env.CLAUDE_CODE_ENTRYPOINT && { 500: entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, 501: }), 502: ...(process.env.CLAUDE_AGENT_SDK_VERSION && { 503: agentSdkVersion: process.env.CLAUDE_AGENT_SDK_VERSION, 504: }), 505: isInteractive: String(getIsInteractive()), 506: clientType: getClientType(), 507: ...(processMetrics && { processMetrics }), 508: sweBenchRunId: process.env.SWE_BENCH_RUN_ID || '', 509: sweBenchInstanceId: process.env.SWE_BENCH_INSTANCE_ID || '', 510: sweBenchTaskId: process.env.SWE_BENCH_TASK_ID || '', 511: // Swarm/team agent identification 512: // Priority: AsyncLocalStorage context (subagents) > env vars (swarm teammates) 513: ...getAgentIdentification(), 514: // Subscription tier for DAU-by-tier analytics 515: ...(getSubscriptionType() && { 516: subscriptionType: getSubscriptionType()!, 517: }), 518: // Assistant mode tag — lives outside memoized buildEnvContext() because 519: // setKairosActive() runs at main.tsx:~1648, after the first event may 520: // have already fired and memoized the env. Read fresh per-event instead. 521: ...(feature('KAIROS') && getKairosActive() 522: ? { kairosActive: true as const } 523: : {}), 524: ...(repoRemoteHash && { rh: repoRemoteHash }), 525: } 526: return metadata 527: } 528: export type FirstPartyEventLoggingCoreMetadata = { 529: session_id: string 530: model: string 531: user_type: string 532: betas?: string 533: entrypoint?: string 534: agent_sdk_version?: string 535: is_interactive: boolean 536: client_type: string 537: swe_bench_run_id?: string 538: swe_bench_instance_id?: string 539: swe_bench_task_id?: string 540: agent_id?: string 541: parent_session_id?: string 542: agent_type?: 'teammate' | 'subagent' | 'standalone' 543: team_name?: string 544: } 545: export type FirstPartyEventLoggingMetadata = { 546: env: EnvironmentMetadata 547: process?: string 548: auth?: PublicApiAuth 549: core: FirstPartyEventLoggingCoreMetadata 550: additional: Record<string, unknown> 551: } 552: export function to1PEventFormat( 553: metadata: EventMetadata, 554: userMetadata: CoreUserData, 555: additionalMetadata: Record<string, unknown> = {}, 556: ): FirstPartyEventLoggingMetadata { 557: const { 558: envContext, 559: processMetrics, 560: rh, 561: kairosActive, 562: skillMode, 563: observerMode, 564: ...coreFields 565: } = metadata 566: const env: EnvironmentMetadata = { 567: platform: envContext.platform, 568: platform_raw: envContext.platformRaw, 569: arch: envContext.arch, 570: node_version: envContext.nodeVersion, 571: terminal: envContext.terminal || 'unknown', 572: package_managers: envContext.packageManagers, 573: runtimes: envContext.runtimes, 574: is_running_with_bun: envContext.isRunningWithBun, 575: is_ci: envContext.isCi, 576: is_claubbit: envContext.isClaubbit, 577: is_claude_code_remote: envContext.isClaudeCodeRemote, 578: is_local_agent_mode: envContext.isLocalAgentMode, 579: is_conductor: envContext.isConductor, 580: is_github_action: envContext.isGithubAction, 581: is_claude_code_action: envContext.isClaudeCodeAction, 582: is_claude_ai_auth: envContext.isClaudeAiAuth, 583: version: envContext.version, 584: build_time: envContext.buildTime, 585: deployment_environment: envContext.deploymentEnvironment, 586: } 587: if (envContext.remoteEnvironmentType) { 588: env.remote_environment_type = envContext.remoteEnvironmentType 589: } 590: if (feature('COWORKER_TYPE_TELEMETRY') && envContext.coworkerType) { 591: env.coworker_type = envContext.coworkerType 592: } 593: if (envContext.claudeCodeContainerId) { 594: env.claude_code_container_id = envContext.claudeCodeContainerId 595: } 596: if (envContext.claudeCodeRemoteSessionId) { 597: env.claude_code_remote_session_id = envContext.claudeCodeRemoteSessionId 598: } 599: if (envContext.tags) { 600: env.tags = envContext.tags 601: .split(',') 602: .map(t => t.trim()) 603: .filter(Boolean) 604: } 605: if (envContext.githubEventName) { 606: env.github_event_name = envContext.githubEventName 607: } 608: if (envContext.githubActionsRunnerEnvironment) { 609: env.github_actions_runner_environment = 610: envContext.githubActionsRunnerEnvironment 611: } 612: if (envContext.githubActionsRunnerOs) { 613: env.github_actions_runner_os = envContext.githubActionsRunnerOs 614: } 615: if (envContext.githubActionRef) { 616: env.github_action_ref = envContext.githubActionRef 617: } 618: if (envContext.wslVersion) { 619: env.wsl_version = envContext.wslVersion 620: } 621: if (envContext.linuxDistroId) { 622: env.linux_distro_id = envContext.linuxDistroId 623: } 624: if (envContext.linuxDistroVersion) { 625: env.linux_distro_version = envContext.linuxDistroVersion 626: } 627: if (envContext.linuxKernel) { 628: env.linux_kernel = envContext.linuxKernel 629: } 630: if (envContext.vcs) { 631: env.vcs = envContext.vcs 632: } 633: if (envContext.versionBase) { 634: env.version_base = envContext.versionBase 635: } 636: const core: FirstPartyEventLoggingCoreMetadata = { 637: session_id: coreFields.sessionId, 638: model: coreFields.model, 639: user_type: coreFields.userType, 640: is_interactive: coreFields.isInteractive === 'true', 641: client_type: coreFields.clientType, 642: } 643: if (coreFields.betas) { 644: core.betas = coreFields.betas 645: } 646: if (coreFields.entrypoint) { 647: core.entrypoint = coreFields.entrypoint 648: } 649: if (coreFields.agentSdkVersion) { 650: core.agent_sdk_version = coreFields.agentSdkVersion 651: } 652: if (coreFields.sweBenchRunId) { 653: core.swe_bench_run_id = coreFields.sweBenchRunId 654: } 655: if (coreFields.sweBenchInstanceId) { 656: core.swe_bench_instance_id = coreFields.sweBenchInstanceId 657: } 658: if (coreFields.sweBenchTaskId) { 659: core.swe_bench_task_id = coreFields.sweBenchTaskId 660: } 661: if (coreFields.agentId) { 662: core.agent_id = coreFields.agentId 663: } 664: if (coreFields.parentSessionId) { 665: core.parent_session_id = coreFields.parentSessionId 666: } 667: if (coreFields.agentType) { 668: core.agent_type = coreFields.agentType 669: } 670: if (coreFields.teamName) { 671: core.team_name = coreFields.teamName 672: } 673: if (userMetadata.githubActionsMetadata) { 674: const ghMeta = userMetadata.githubActionsMetadata 675: env.github_actions_metadata = { 676: actor_id: ghMeta.actorId, 677: repository_id: ghMeta.repositoryId, 678: repository_owner_id: ghMeta.repositoryOwnerId, 679: } 680: } 681: let auth: PublicApiAuth | undefined 682: if (userMetadata.accountUuid || userMetadata.organizationUuid) { 683: auth = { 684: account_uuid: userMetadata.accountUuid, 685: organization_uuid: userMetadata.organizationUuid, 686: } 687: } 688: return { 689: env, 690: ...(processMetrics && { 691: process: Buffer.from(jsonStringify(processMetrics)).toString('base64'), 692: }), 693: ...(auth && { auth }), 694: core, 695: additional: { 696: ...(rh && { rh }), 697: ...(kairosActive && { is_assistant_mode: true }), 698: ...(skillMode && { skill_mode: skillMode }), 699: ...(observerMode && { observer_mode: observerMode }), 700: ...additionalMetadata, 701: }, 702: } 703: }

File: src/services/analytics/sink.ts

typescript 1: import { trackDatadogEvent } from './datadog.js' 2: import { logEventTo1P, shouldSampleEvent } from './firstPartyEventLogger.js' 3: import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from './growthbook.js' 4: import { attachAnalyticsSink, stripProtoFields } from './index.js' 5: import { isSinkKilled } from './sinkKillswitch.js' 6: type LogEventMetadata = { [key: string]: boolean | number | undefined } 7: const DATADOG_GATE_NAME = 'tengu_log_datadog_events' 8: let isDatadogGateEnabled: boolean | undefined = undefined 9: function shouldTrackDatadog(): boolean { 10: if (isSinkKilled('datadog')) { 11: return false 12: } 13: if (isDatadogGateEnabled !== undefined) { 14: return isDatadogGateEnabled 15: } 16: try { 17: return checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME) 18: } catch { 19: return false 20: } 21: } 22: function logEventImpl(eventName: string, metadata: LogEventMetadata): void { 23: const sampleResult = shouldSampleEvent(eventName) 24: if (sampleResult === 0) { 25: return 26: } 27: const metadataWithSampleRate = 28: sampleResult !== null 29: ? { ...metadata, sample_rate: sampleResult } 30: : metadata 31: if (shouldTrackDatadog()) { 32: void trackDatadogEvent(eventName, stripProtoFields(metadataWithSampleRate)) 33: } 34: logEventTo1P(eventName, metadataWithSampleRate) 35: } 36: function logEventAsyncImpl( 37: eventName: string, 38: metadata: LogEventMetadata, 39: ): Promise<void> { 40: logEventImpl(eventName, metadata) 41: return Promise.resolve() 42: } 43: export function initializeAnalyticsGates(): void { 44: isDatadogGateEnabled = 45: checkStatsigFeatureGate_CACHED_MAY_BE_STALE(DATADOG_GATE_NAME) 46: } 47: export function initializeAnalyticsSink(): void { 48: attachAnalyticsSink({ 49: logEvent: logEventImpl, 50: logEventAsync: logEventAsyncImpl, 51: }) 52: }

File: src/services/analytics/sinkKillswitch.ts

typescript 1: import { getDynamicConfig_CACHED_MAY_BE_STALE } from './growthbook.js' 2: const SINK_KILLSWITCH_CONFIG_NAME = 'tengu_frond_boric' 3: export type SinkName = 'datadog' | 'firstParty' 4: export function isSinkKilled(sink: SinkName): boolean { 5: const config = getDynamicConfig_CACHED_MAY_BE_STALE< 6: Partial<Record<SinkName, boolean>> 7: >(SINK_KILLSWITCH_CONFIG_NAME, {}) 8: return config?.[sink] === true 9: }

File: src/services/api/adminRequests.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js' 4: export type AdminRequestType = 'limit_increase' | 'seat_upgrade' 5: export type AdminRequestStatus = 'pending' | 'approved' | 'dismissed' 6: export type AdminRequestSeatUpgradeDetails = { 7: message?: string | null 8: current_seat_tier?: string | null 9: } 10: export type AdminRequestCreateParams = 11: | { 12: request_type: 'limit_increase' 13: details: null 14: } 15: | { 16: request_type: 'seat_upgrade' 17: details: AdminRequestSeatUpgradeDetails 18: } 19: export type AdminRequest = { 20: uuid: string 21: status: AdminRequestStatus 22: requester_uuid?: string | null 23: created_at: string 24: } & ( 25: | { 26: request_type: 'limit_increase' 27: details: null 28: } 29: | { 30: request_type: 'seat_upgrade' 31: details: AdminRequestSeatUpgradeDetails 32: } 33: ) 34: export async function createAdminRequest( 35: params: AdminRequestCreateParams, 36: ): Promise<AdminRequest> { 37: const { accessToken, orgUUID } = await prepareApiRequest() 38: const headers = { 39: ...getOAuthHeaders(accessToken), 40: 'x-organization-uuid': orgUUID, 41: } 42: const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/admin_requests` 43: const response = await axios.post<AdminRequest>(url, params, { headers }) 44: return response.data 45: } 46: export async function getMyAdminRequests( 47: requestType: AdminRequestType, 48: statuses: AdminRequestStatus[], 49: ): Promise<AdminRequest[] | null> { 50: const { accessToken, orgUUID } = await prepareApiRequest() 51: const headers = { 52: ...getOAuthHeaders(accessToken), 53: 'x-organization-uuid': orgUUID, 54: } 55: let url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/admin_requests/me?request_type=${requestType}` 56: for (const status of statuses) { 57: url += `&statuses=${status}` 58: } 59: const response = await axios.get<AdminRequest[] | null>(url, { 60: headers, 61: }) 62: return response.data 63: } 64: type AdminRequestEligibilityResponse = { 65: request_type: AdminRequestType 66: is_allowed: boolean 67: } 68: export async function checkAdminRequestEligibility( 69: requestType: AdminRequestType, 70: ): Promise<AdminRequestEligibilityResponse | null> { 71: const { accessToken, orgUUID } = await prepareApiRequest() 72: const headers = { 73: ...getOAuthHeaders(accessToken), 74: 'x-organization-uuid': orgUUID, 75: } 76: const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/admin_requests/eligibility?request_type=${requestType}` 77: const response = await axios.get<AdminRequestEligibilityResponse>(url, { 78: headers, 79: }) 80: return response.data 81: }

File: src/services/api/bootstrap.ts

typescript 1: import axios from 'axios' 2: import isEqual from 'lodash-es/isEqual.js' 3: import { 4: getAnthropicApiKey, 5: getClaudeAIOAuthTokens, 6: hasProfileScope, 7: } from 'src/utils/auth.js' 8: import { z } from 'zod' 9: import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js' 10: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 11: import { logForDebugging } from '../../utils/debug.js' 12: import { withOAuth401Retry } from '../../utils/http.js' 13: import { lazySchema } from '../../utils/lazySchema.js' 14: import { logError } from '../../utils/log.js' 15: import { getAPIProvider } from '../../utils/model/providers.js' 16: import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 17: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 18: const bootstrapResponseSchema = lazySchema(() => 19: z.object({ 20: client_data: z.record(z.unknown()).nullish(), 21: additional_model_options: z 22: .array( 23: z 24: .object({ 25: model: z.string(), 26: name: z.string(), 27: description: z.string(), 28: }) 29: .transform(({ model, name, description }) => ({ 30: value: model, 31: label: name, 32: description, 33: })), 34: ) 35: .nullish(), 36: }), 37: ) 38: type BootstrapResponse = z.infer<ReturnType<typeof bootstrapResponseSchema>> 39: async function fetchBootstrapAPI(): Promise<BootstrapResponse | null> { 40: if (isEssentialTrafficOnly()) { 41: logForDebugging('[Bootstrap] Skipped: Nonessential traffic disabled') 42: return null 43: } 44: if (getAPIProvider() !== 'firstParty') { 45: logForDebugging('[Bootstrap] Skipped: 3P provider') 46: return null 47: } 48: const apiKey = getAnthropicApiKey() 49: const hasUsableOAuth = 50: getClaudeAIOAuthTokens()?.accessToken && hasProfileScope() 51: if (!hasUsableOAuth && !apiKey) { 52: logForDebugging('[Bootstrap] Skipped: no usable OAuth or API key') 53: return null 54: } 55: const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_cli/bootstrap` 56: try { 57: return await withOAuth401Retry(async () => { 58: const token = getClaudeAIOAuthTokens()?.accessToken 59: let authHeaders: Record<string, string> 60: if (token && hasProfileScope()) { 61: authHeaders = { 62: Authorization: `Bearer ${token}`, 63: 'anthropic-beta': OAUTH_BETA_HEADER, 64: } 65: } else if (apiKey) { 66: authHeaders = { 'x-api-key': apiKey } 67: } else { 68: logForDebugging('[Bootstrap] No auth available on retry, aborting') 69: return null 70: } 71: logForDebugging('[Bootstrap] Fetching') 72: const response = await axios.get<unknown>(endpoint, { 73: headers: { 74: 'Content-Type': 'application/json', 75: 'User-Agent': getClaudeCodeUserAgent(), 76: ...authHeaders, 77: }, 78: timeout: 5000, 79: }) 80: const parsed = bootstrapResponseSchema().safeParse(response.data) 81: if (!parsed.success) { 82: logForDebugging( 83: `[Bootstrap] Response failed validation: ${parsed.error.message}`, 84: ) 85: return null 86: } 87: logForDebugging('[Bootstrap] Fetch ok') 88: return parsed.data 89: }) 90: } catch (error) { 91: logForDebugging( 92: `[Bootstrap] Fetch failed: ${axios.isAxiosError(error) ? (error.response?.status ?? error.code) : 'unknown'}`, 93: ) 94: throw error 95: } 96: } 97: export async function fetchBootstrapData(): Promise<void> { 98: try { 99: const response = await fetchBootstrapAPI() 100: if (!response) return 101: const clientData = response.client_data ?? null 102: const additionalModelOptions = response.additional_model_options ?? [] 103: const config = getGlobalConfig() 104: if ( 105: isEqual(config.clientDataCache, clientData) && 106: isEqual(config.additionalModelOptionsCache, additionalModelOptions) 107: ) { 108: logForDebugging('[Bootstrap] Cache unchanged, skipping write') 109: return 110: } 111: logForDebugging('[Bootstrap] Cache updated, persisting to disk') 112: saveGlobalConfig(current => ({ 113: ...current, 114: clientDataCache: clientData, 115: additionalModelOptionsCache: additionalModelOptions, 116: })) 117: } catch (error) { 118: logError(error) 119: } 120: }

File: src/services/api/claude.ts

typescript 1: import type { 2: BetaContentBlock, 3: BetaContentBlockParam, 4: BetaImageBlockParam, 5: BetaJSONOutputFormat, 6: BetaMessage, 7: BetaMessageDeltaUsage, 8: BetaMessageStreamParams, 9: BetaOutputConfig, 10: BetaRawMessageStreamEvent, 11: BetaRequestDocumentBlock, 12: BetaStopReason, 13: BetaToolChoiceAuto, 14: BetaToolChoiceTool, 15: BetaToolResultBlockParam, 16: BetaToolUnion, 17: BetaUsage, 18: BetaMessageParam as MessageParam, 19: } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 20: import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 21: import type { Stream } from '@anthropic-ai/sdk/streaming.mjs' 22: import { randomUUID } from 'crypto' 23: import { 24: getAPIProvider, 25: isFirstPartyAnthropicBaseUrl, 26: } from 'src/utils/model/providers.js' 27: import { 28: getAttributionHeader, 29: getCLISyspromptPrefix, 30: } from '../../constants/system.js' 31: import { 32: getEmptyToolPermissionContext, 33: type QueryChainTracking, 34: type Tool, 35: type ToolPermissionContext, 36: type Tools, 37: toolMatchesName, 38: } from '../../Tool.js' 39: import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 40: import { 41: type ConnectorTextBlock, 42: type ConnectorTextDelta, 43: isConnectorTextBlock, 44: } from '../../types/connectorText.js' 45: import type { 46: AssistantMessage, 47: Message, 48: StreamEvent, 49: SystemAPIErrorMessage, 50: UserMessage, 51: } from '../../types/message.js' 52: import { 53: type CacheScope, 54: logAPIPrefix, 55: splitSysPromptPrefix, 56: toolToAPISchema, 57: } from '../../utils/api.js' 58: import { getOauthAccountInfo } from '../../utils/auth.js' 59: import { 60: getBedrockExtraBodyParamsBetas, 61: getMergedBetas, 62: getModelBetas, 63: } from '../../utils/betas.js' 64: import { getOrCreateUserID } from '../../utils/config.js' 65: import { 66: CAPPED_DEFAULT_MAX_TOKENS, 67: getModelMaxOutputTokens, 68: getSonnet1mExpTreatmentEnabled, 69: } from '../../utils/context.js' 70: import { resolveAppliedEffort } from '../../utils/effort.js' 71: import { isEnvTruthy } from '../../utils/envUtils.js' 72: import { errorMessage } from '../../utils/errors.js' 73: import { computeFingerprintFromMessages } from '../../utils/fingerprint.js' 74: import { captureAPIRequest, logError } from '../../utils/log.js' 75: import { 76: createAssistantAPIErrorMessage, 77: createUserMessage, 78: ensureToolResultPairing, 79: normalizeContentFromAPI, 80: normalizeMessagesForAPI, 81: stripAdvisorBlocks, 82: stripCallerFieldFromAssistantMessage, 83: stripToolReferenceBlocksFromUserMessage, 84: } from '../../utils/messages.js' 85: import { 86: getDefaultOpusModel, 87: getDefaultSonnetModel, 88: getSmallFastModel, 89: isNonCustomOpusModel, 90: } from '../../utils/model/model.js' 91: import { 92: asSystemPrompt, 93: type SystemPrompt, 94: } from '../../utils/systemPromptType.js' 95: import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js' 96: import { getDynamicConfig_BLOCKS_ON_INIT } from '../analytics/growthbook.js' 97: import { 98: currentLimits, 99: extractQuotaStatusFromError, 100: extractQuotaStatusFromHeaders, 101: } from '../claudeAiLimits.js' 102: import { getAPIContextManagement } from '../compact/apiMicrocompact.js' 103: const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') 104: ? (require('../../utils/permissions/autoModeState.js') as typeof import('../../utils/permissions/autoModeState.js')) 105: : null 106: import { feature } from 'bun:bundle' 107: import type { ClientOptions } from '@anthropic-ai/sdk' 108: import { 109: APIConnectionTimeoutError, 110: APIError, 111: APIUserAbortError, 112: } from '@anthropic-ai/sdk/error' 113: import { 114: getAfkModeHeaderLatched, 115: getCacheEditingHeaderLatched, 116: getFastModeHeaderLatched, 117: getLastApiCompletionTimestamp, 118: getPromptCache1hAllowlist, 119: getPromptCache1hEligible, 120: getSessionId, 121: getThinkingClearLatched, 122: setAfkModeHeaderLatched, 123: setCacheEditingHeaderLatched, 124: setFastModeHeaderLatched, 125: setLastMainRequestId, 126: setPromptCache1hAllowlist, 127: setPromptCache1hEligible, 128: setThinkingClearLatched, 129: } from 'src/bootstrap/state.js' 130: import { 131: AFK_MODE_BETA_HEADER, 132: CONTEXT_1M_BETA_HEADER, 133: CONTEXT_MANAGEMENT_BETA_HEADER, 134: EFFORT_BETA_HEADER, 135: FAST_MODE_BETA_HEADER, 136: PROMPT_CACHING_SCOPE_BETA_HEADER, 137: REDACT_THINKING_BETA_HEADER, 138: STRUCTURED_OUTPUTS_BETA_HEADER, 139: TASK_BUDGETS_BETA_HEADER, 140: } from 'src/constants/betas.js' 141: import type { QuerySource } from 'src/constants/querySource.js' 142: import type { Notification } from 'src/context/notifications.js' 143: import { addToTotalSessionCost } from 'src/cost-tracker.js' 144: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 145: import type { AgentId } from 'src/types/ids.js' 146: import { 147: ADVISOR_TOOL_INSTRUCTIONS, 148: getExperimentAdvisorModels, 149: isAdvisorEnabled, 150: isValidAdvisorModel, 151: modelSupportsAdvisor, 152: } from 'src/utils/advisor.js' 153: import { getAgentContext } from 'src/utils/agentContext.js' 154: import { isClaudeAISubscriber } from 'src/utils/auth.js' 155: import { 156: getToolSearchBetaHeader, 157: modelSupportsStructuredOutputs, 158: shouldIncludeFirstPartyOnlyBetas, 159: shouldUseGlobalCacheScope, 160: } from 'src/utils/betas.js' 161: import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from 'src/utils/claudeInChrome/common.js' 162: import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from 'src/utils/claudeInChrome/prompt.js' 163: import { getMaxThinkingTokensForModel } from 'src/utils/context.js' 164: import { logForDebugging } from 'src/utils/debug.js' 165: import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js' 166: import { type EffortValue, modelSupportsEffort } from 'src/utils/effort.js' 167: import { 168: isFastModeAvailable, 169: isFastModeCooldown, 170: isFastModeEnabled, 171: isFastModeSupportedByModel, 172: } from 'src/utils/fastMode.js' 173: import { returnValue } from 'src/utils/generators.js' 174: import { headlessProfilerCheckpoint } from 'src/utils/headlessProfiler.js' 175: import { isMcpInstructionsDeltaEnabled } from 'src/utils/mcpInstructionsDelta.js' 176: import { calculateUSDCost } from 'src/utils/modelCost.js' 177: import { endQueryProfile, queryCheckpoint } from 'src/utils/queryProfiler.js' 178: import { 179: modelSupportsAdaptiveThinking, 180: modelSupportsThinking, 181: type ThinkingConfig, 182: } from 'src/utils/thinking.js' 183: import { 184: extractDiscoveredToolNames, 185: isDeferredToolsDeltaEnabled, 186: isToolSearchEnabled, 187: } from 'src/utils/toolSearch.js' 188: import { API_MAX_MEDIA_PER_REQUEST } from '../../constants/apiLimits.js' 189: import { ADVISOR_BETA_HEADER } from '../../constants/betas.js' 190: import { 191: formatDeferredToolLine, 192: isDeferredTool, 193: TOOL_SEARCH_TOOL_NAME, 194: } from '../../tools/ToolSearchTool/prompt.js' 195: import { count } from '../../utils/array.js' 196: import { insertBlockAfterToolResults } from '../../utils/contentArray.js' 197: import { validateBoundedIntEnvVar } from '../../utils/envValidation.js' 198: import { safeParseJSON } from '../../utils/json.js' 199: import { getInferenceProfileBackingModel } from '../../utils/model/bedrock.js' 200: import { 201: normalizeModelStringForAPI, 202: parseUserSpecifiedModel, 203: } from '../../utils/model/model.js' 204: import { 205: startSessionActivity, 206: stopSessionActivity, 207: } from '../../utils/sessionActivity.js' 208: import { jsonStringify } from '../../utils/slowOperations.js' 209: import { 210: isBetaTracingEnabled, 211: type LLMRequestNewContext, 212: startLLMRequestSpan, 213: } from '../../utils/telemetry/sessionTracing.js' 214: import { 215: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 216: logEvent, 217: } from '../analytics/index.js' 218: import { 219: consumePendingCacheEdits, 220: getPinnedCacheEdits, 221: markToolsSentToAPIState, 222: pinCacheEdits, 223: } from '../compact/microCompact.js' 224: import { getInitializationStatus } from '../lsp/manager.js' 225: import { isToolFromMcpServer } from '../mcp/utils.js' 226: import { withStreamingVCR, withVCR } from '../vcr.js' 227: import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js' 228: import { 229: API_ERROR_MESSAGE_PREFIX, 230: CUSTOM_OFF_SWITCH_MESSAGE, 231: getAssistantMessageFromError, 232: getErrorMessageIfRefusal, 233: } from './errors.js' 234: import { 235: EMPTY_USAGE, 236: type GlobalCacheStrategy, 237: logAPIError, 238: logAPIQuery, 239: logAPISuccessAndDuration, 240: type NonNullableUsage, 241: } from './logging.js' 242: import { 243: CACHE_TTL_1HOUR_MS, 244: checkResponseForCacheBreak, 245: recordPromptState, 246: } from './promptCacheBreakDetection.js' 247: import { 248: CannotRetryError, 249: FallbackTriggeredError, 250: is529Error, 251: type RetryContext, 252: withRetry, 253: } from './withRetry.js' 254: type JsonValue = string | number | boolean | null | JsonObject | JsonArray 255: type JsonObject = { [key: string]: JsonValue } 256: type JsonArray = JsonValue[] 257: export function getExtraBodyParams(betaHeaders?: string[]): JsonObject { 258: const extraBodyStr = process.env.CLAUDE_CODE_EXTRA_BODY 259: let result: JsonObject = {} 260: if (extraBodyStr) { 261: try { 262: const parsed = safeParseJSON(extraBodyStr) 263: if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { 264: result = { ...(parsed as JsonObject) } 265: } else { 266: logForDebugging( 267: `CLAUDE_CODE_EXTRA_BODY env var must be a JSON object, but was given ${extraBodyStr}`, 268: { level: 'error' }, 269: ) 270: } 271: } catch (error) { 272: logForDebugging( 273: `Error parsing CLAUDE_CODE_EXTRA_BODY: ${errorMessage(error)}`, 274: { level: 'error' }, 275: ) 276: } 277: } 278: if ( 279: feature('ANTI_DISTILLATION_CC') 280: ? process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' && 281: shouldIncludeFirstPartyOnlyBetas() && 282: getFeatureValue_CACHED_MAY_BE_STALE( 283: 'tengu_anti_distill_fake_tool_injection', 284: false, 285: ) 286: : false 287: ) { 288: result.anti_distillation = ['fake_tools'] 289: } 290: if (betaHeaders && betaHeaders.length > 0) { 291: if (result.anthropic_beta && Array.isArray(result.anthropic_beta)) { 292: const existingHeaders = result.anthropic_beta as string[] 293: const newHeaders = betaHeaders.filter( 294: header => !existingHeaders.includes(header), 295: ) 296: result.anthropic_beta = [...existingHeaders, ...newHeaders] 297: } else { 298: result.anthropic_beta = betaHeaders 299: } 300: } 301: return result 302: } 303: export function getPromptCachingEnabled(model: string): boolean { 304: if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING)) return false 305: if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_HAIKU)) { 306: const smallFastModel = getSmallFastModel() 307: if (model === smallFastModel) return false 308: } 309: if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_SONNET)) { 310: const defaultSonnet = getDefaultSonnetModel() 311: if (model === defaultSonnet) return false 312: } 313: if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING_OPUS)) { 314: const defaultOpus = getDefaultOpusModel() 315: if (model === defaultOpus) return false 316: } 317: return true 318: } 319: export function getCacheControl({ 320: scope, 321: querySource, 322: }: { 323: scope?: CacheScope 324: querySource?: QuerySource 325: } = {}): { 326: type: 'ephemeral' 327: ttl?: '1h' 328: scope?: CacheScope 329: } { 330: return { 331: type: 'ephemeral', 332: ...(should1hCacheTTL(querySource) && { ttl: '1h' }), 333: ...(scope === 'global' && { scope }), 334: } 335: } 336: function should1hCacheTTL(querySource?: QuerySource): boolean { 337: if ( 338: getAPIProvider() === 'bedrock' && 339: isEnvTruthy(process.env.ENABLE_PROMPT_CACHING_1H_BEDROCK) 340: ) { 341: return true 342: } 343: let userEligible = getPromptCache1hEligible() 344: if (userEligible === null) { 345: userEligible = 346: process.env.USER_TYPE === 'ant' || 347: (isClaudeAISubscriber() && !currentLimits.isUsingOverage) 348: setPromptCache1hEligible(userEligible) 349: } 350: if (!userEligible) return false 351: let allowlist = getPromptCache1hAllowlist() 352: if (allowlist === null) { 353: const config = getFeatureValue_CACHED_MAY_BE_STALE<{ 354: allowlist?: string[] 355: }>('tengu_prompt_cache_1h_config', {}) 356: allowlist = config.allowlist ?? [] 357: setPromptCache1hAllowlist(allowlist) 358: } 359: return ( 360: querySource !== undefined && 361: allowlist.some(pattern => 362: pattern.endsWith('*') 363: ? querySource.startsWith(pattern.slice(0, -1)) 364: : querySource === pattern, 365: ) 366: ) 367: } 368: function configureEffortParams( 369: effortValue: EffortValue | undefined, 370: outputConfig: BetaOutputConfig, 371: extraBodyParams: Record<string, unknown>, 372: betas: string[], 373: model: string, 374: ): void { 375: if (!modelSupportsEffort(model) || 'effort' in outputConfig) { 376: return 377: } 378: if (effortValue === undefined) { 379: betas.push(EFFORT_BETA_HEADER) 380: } else if (typeof effortValue === 'string') { 381: outputConfig.effort = effortValue 382: betas.push(EFFORT_BETA_HEADER) 383: } else if (process.env.USER_TYPE === 'ant') { 384: const existingInternal = 385: (extraBodyParams.anthropic_internal as Record<string, unknown>) || {} 386: extraBodyParams.anthropic_internal = { 387: ...existingInternal, 388: effort_override: effortValue, 389: } 390: } 391: } 392: type TaskBudgetParam = { 393: type: 'tokens' 394: total: number 395: remaining?: number 396: } 397: export function configureTaskBudgetParams( 398: taskBudget: Options['taskBudget'], 399: outputConfig: BetaOutputConfig & { task_budget?: TaskBudgetParam }, 400: betas: string[], 401: ): void { 402: if ( 403: !taskBudget || 404: 'task_budget' in outputConfig || 405: !shouldIncludeFirstPartyOnlyBetas() 406: ) { 407: return 408: } 409: outputConfig.task_budget = { 410: type: 'tokens', 411: total: taskBudget.total, 412: ...(taskBudget.remaining !== undefined && { 413: remaining: taskBudget.remaining, 414: }), 415: } 416: if (!betas.includes(TASK_BUDGETS_BETA_HEADER)) { 417: betas.push(TASK_BUDGETS_BETA_HEADER) 418: } 419: } 420: export function getAPIMetadata() { 421: let extra: JsonObject = {} 422: const extraStr = process.env.CLAUDE_CODE_EXTRA_METADATA 423: if (extraStr) { 424: const parsed = safeParseJSON(extraStr, false) 425: if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { 426: extra = parsed as JsonObject 427: } else { 428: logForDebugging( 429: `CLAUDE_CODE_EXTRA_METADATA env var must be a JSON object, but was given ${extraStr}`, 430: { level: 'error' }, 431: ) 432: } 433: } 434: return { 435: user_id: jsonStringify({ 436: ...extra, 437: device_id: getOrCreateUserID(), 438: account_uuid: getOauthAccountInfo()?.accountUuid ?? '', 439: session_id: getSessionId(), 440: }), 441: } 442: } 443: export async function verifyApiKey( 444: apiKey: string, 445: isNonInteractiveSession: boolean, 446: ): Promise<boolean> { 447: // Skip API verification if running in print mode (isNonInteractiveSession) 448: if (isNonInteractiveSession) { 449: return true 450: } 451: try { 452: // WARNING: if you change this to use a non-Haiku model, this request will fail in 1P unless it uses getCLISyspromptPrefix. 453: const model = getSmallFastModel() 454: const betas = getModelBetas(model) 455: return await returnValue( 456: withRetry( 457: () => 458: getAnthropicClient({ 459: apiKey, 460: maxRetries: 3, 461: model, 462: source: 'verify_api_key', 463: }), 464: async anthropic => { 465: const messages: MessageParam[] = [{ role: 'user', content: 'test' }] 466: await anthropic.beta.messages.create({ 467: model, 468: max_tokens: 1, 469: messages, 470: temperature: 1, 471: ...(betas.length > 0 && { betas }), 472: metadata: getAPIMetadata(), 473: ...getExtraBodyParams(), 474: }) 475: return true 476: }, 477: { maxRetries: 2, model, thinkingConfig: { type: 'disabled' } }, 478: ), 479: ) 480: } catch (errorFromRetry) { 481: let error = errorFromRetry 482: if (errorFromRetry instanceof CannotRetryError) { 483: error = errorFromRetry.originalError 484: } 485: logError(error) 486: if ( 487: error instanceof Error && 488: error.message.includes( 489: '{"type":"error","error":{"type":"authentication_error","message":"invalid x-api-key"}}', 490: ) 491: ) { 492: return false 493: } 494: throw error 495: } 496: } 497: export function userMessageToMessageParam( 498: message: UserMessage, 499: addCache = false, 500: enablePromptCaching: boolean, 501: querySource?: QuerySource, 502: ): MessageParam { 503: if (addCache) { 504: if (typeof message.message.content === 'string') { 505: return { 506: role: 'user', 507: content: [ 508: { 509: type: 'text', 510: text: message.message.content, 511: ...(enablePromptCaching && { 512: cache_control: getCacheControl({ querySource }), 513: }), 514: }, 515: ], 516: } 517: } else { 518: return { 519: role: 'user', 520: content: message.message.content.map((_, i) => ({ 521: ..._, 522: ...(i === message.message.content.length - 1 523: ? enablePromptCaching 524: ? { cache_control: getCacheControl({ querySource }) } 525: : {} 526: : {}), 527: })), 528: } 529: } 530: } 531: return { 532: role: 'user', 533: content: Array.isArray(message.message.content) 534: ? [...message.message.content] 535: : message.message.content, 536: } 537: } 538: export function assistantMessageToMessageParam( 539: message: AssistantMessage, 540: addCache = false, 541: enablePromptCaching: boolean, 542: querySource?: QuerySource, 543: ): MessageParam { 544: if (addCache) { 545: if (typeof message.message.content === 'string') { 546: return { 547: role: 'assistant', 548: content: [ 549: { 550: type: 'text', 551: text: message.message.content, 552: ...(enablePromptCaching && { 553: cache_control: getCacheControl({ querySource }), 554: }), 555: }, 556: ], 557: } 558: } else { 559: return { 560: role: 'assistant', 561: content: message.message.content.map((_, i) => ({ 562: ..._, 563: ...(i === message.message.content.length - 1 && 564: _.type !== 'thinking' && 565: _.type !== 'redacted_thinking' && 566: (feature('CONNECTOR_TEXT') ? !isConnectorTextBlock(_) : true) 567: ? enablePromptCaching 568: ? { cache_control: getCacheControl({ querySource }) } 569: : {} 570: : {}), 571: })), 572: } 573: } 574: } 575: return { 576: role: 'assistant', 577: content: message.message.content, 578: } 579: } 580: export type Options = { 581: getToolPermissionContext: () => Promise<ToolPermissionContext> 582: model: string 583: toolChoice?: BetaToolChoiceTool | BetaToolChoiceAuto | undefined 584: isNonInteractiveSession: boolean 585: extraToolSchemas?: BetaToolUnion[] 586: maxOutputTokensOverride?: number 587: fallbackModel?: string 588: onStreamingFallback?: () => void 589: querySource: QuerySource 590: agents: AgentDefinition[] 591: allowedAgentTypes?: string[] 592: hasAppendSystemPrompt: boolean 593: fetchOverride?: ClientOptions['fetch'] 594: enablePromptCaching?: boolean 595: skipCacheWrite?: boolean 596: temperatureOverride?: number 597: effortValue?: EffortValue 598: mcpTools: Tools 599: hasPendingMcpServers?: boolean 600: queryTracking?: QueryChainTracking 601: agentId?: AgentId 602: outputFormat?: BetaJSONOutputFormat 603: fastMode?: boolean 604: advisorModel?: string 605: addNotification?: (notif: Notification) => void 606: taskBudget?: { total: number; remaining?: number } 607: } 608: export async function queryModelWithoutStreaming({ 609: messages, 610: systemPrompt, 611: thinkingConfig, 612: tools, 613: signal, 614: options, 615: }: { 616: messages: Message[] 617: systemPrompt: SystemPrompt 618: thinkingConfig: ThinkingConfig 619: tools: Tools 620: signal: AbortSignal 621: options: Options 622: }): Promise<AssistantMessage> { 623: let assistantMessage: AssistantMessage | undefined 624: for await (const message of withStreamingVCR(messages, async function* () { 625: yield* queryModel( 626: messages, 627: systemPrompt, 628: thinkingConfig, 629: tools, 630: signal, 631: options, 632: ) 633: })) { 634: if (message.type === 'assistant') { 635: assistantMessage = message 636: } 637: } 638: if (!assistantMessage) { 639: if (signal.aborted) { 640: throw new APIUserAbortError() 641: } 642: throw new Error('No assistant message found') 643: } 644: return assistantMessage 645: } 646: export async function* queryModelWithStreaming({ 647: messages, 648: systemPrompt, 649: thinkingConfig, 650: tools, 651: signal, 652: options, 653: }: { 654: messages: Message[] 655: systemPrompt: SystemPrompt 656: thinkingConfig: ThinkingConfig 657: tools: Tools 658: signal: AbortSignal 659: options: Options 660: }): AsyncGenerator< 661: StreamEvent | AssistantMessage | SystemAPIErrorMessage, 662: void 663: > { 664: return yield* withStreamingVCR(messages, async function* () { 665: yield* queryModel( 666: messages, 667: systemPrompt, 668: thinkingConfig, 669: tools, 670: signal, 671: options, 672: ) 673: }) 674: } 675: function shouldDeferLspTool(tool: Tool): boolean { 676: if (!('isLsp' in tool) || !tool.isLsp) { 677: return false 678: } 679: const status = getInitializationStatus() 680: return status.status === 'pending' || status.status === 'not-started' 681: } 682: function getNonstreamingFallbackTimeoutMs(): number { 683: const override = parseInt(process.env.API_TIMEOUT_MS || '', 10) 684: if (override) return override 685: return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ? 120_000 : 300_000 686: } 687: /** 688: * Helper generator for non-streaming API requests. 689: * Encapsulates the common pattern of creating a withRetry generator, 690: * iterating to yield system messages, and returning the final BetaMessage. 691: */ 692: export async function* executeNonStreamingRequest( 693: clientOptions: { 694: model: string 695: fetchOverride?: Options['fetchOverride'] 696: source: string 697: }, 698: retryOptions: { 699: model: string 700: fallbackModel?: string 701: thinkingConfig: ThinkingConfig 702: fastMode?: boolean 703: signal: AbortSignal 704: initialConsecutive529Errors?: number 705: querySource?: QuerySource 706: }, 707: paramsFromContext: (context: RetryContext) => BetaMessageStreamParams, 708: onAttempt: (attempt: number, start: number, maxOutputTokens: number) => void, 709: captureRequest: (params: BetaMessageStreamParams) => void, 710: originatingRequestId?: string | null, 711: ): AsyncGenerator<SystemAPIErrorMessage, BetaMessage> { 712: const fallbackTimeoutMs = getNonstreamingFallbackTimeoutMs() 713: const generator = withRetry( 714: () => 715: getAnthropicClient({ 716: maxRetries: 0, 717: model: clientOptions.model, 718: fetchOverride: clientOptions.fetchOverride, 719: source: clientOptions.source, 720: }), 721: async (anthropic, attempt, context) => { 722: const start = Date.now() 723: const retryParams = paramsFromContext(context) 724: captureRequest(retryParams) 725: onAttempt(attempt, start, retryParams.max_tokens) 726: const adjustedParams = adjustParamsForNonStreaming( 727: retryParams, 728: MAX_NON_STREAMING_TOKENS, 729: ) 730: try { 731: return await anthropic.beta.messages.create( 732: { 733: ...adjustedParams, 734: model: normalizeModelStringForAPI(adjustedParams.model), 735: }, 736: { 737: signal: retryOptions.signal, 738: timeout: fallbackTimeoutMs, 739: }, 740: ) 741: } catch (err) { 742: if (err instanceof APIUserAbortError) throw err 743: logForDiagnosticsNoPII('error', 'cli_nonstreaming_fallback_error') 744: logEvent('tengu_nonstreaming_fallback_error', { 745: model: 746: clientOptions.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 747: error: 748: err instanceof Error 749: ? (err.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 750: : ('unknown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), 751: attempt, 752: timeout_ms: fallbackTimeoutMs, 753: request_id: (originatingRequestId ?? 754: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 755: }) 756: throw err 757: } 758: }, 759: { 760: model: retryOptions.model, 761: fallbackModel: retryOptions.fallbackModel, 762: thinkingConfig: retryOptions.thinkingConfig, 763: ...(isFastModeEnabled() && { fastMode: retryOptions.fastMode }), 764: signal: retryOptions.signal, 765: initialConsecutive529Errors: retryOptions.initialConsecutive529Errors, 766: querySource: retryOptions.querySource, 767: }, 768: ) 769: let e 770: do { 771: e = await generator.next() 772: if (!e.done && e.value.type === 'system') { 773: yield e.value 774: } 775: } while (!e.done) 776: return e.value as BetaMessage 777: } 778: function getPreviousRequestIdFromMessages( 779: messages: Message[], 780: ): string | undefined { 781: for (let i = messages.length - 1; i >= 0; i--) { 782: const msg = messages[i]! 783: if (msg.type === 'assistant' && msg.requestId) { 784: return msg.requestId 785: } 786: } 787: return undefined 788: } 789: function isMedia( 790: block: BetaContentBlockParam, 791: ): block is BetaImageBlockParam | BetaRequestDocumentBlock { 792: return block.type === 'image' || block.type === 'document' 793: } 794: function isToolResult( 795: block: BetaContentBlockParam, 796: ): block is BetaToolResultBlockParam { 797: return block.type === 'tool_result' 798: } 799: export function stripExcessMediaItems( 800: messages: (UserMessage | AssistantMessage)[], 801: limit: number, 802: ): (UserMessage | AssistantMessage)[] { 803: let toRemove = 0 804: for (const msg of messages) { 805: if (!Array.isArray(msg.message.content)) continue 806: for (const block of msg.message.content) { 807: if (isMedia(block)) toRemove++ 808: if (isToolResult(block) && Array.isArray(block.content)) { 809: for (const nested of block.content) { 810: if (isMedia(nested)) toRemove++ 811: } 812: } 813: } 814: } 815: toRemove -= limit 816: if (toRemove <= 0) return messages 817: return messages.map(msg => { 818: if (toRemove <= 0) return msg 819: const content = msg.message.content 820: if (!Array.isArray(content)) return msg 821: const before = toRemove 822: const stripped = content 823: .map(block => { 824: if ( 825: toRemove <= 0 || 826: !isToolResult(block) || 827: !Array.isArray(block.content) 828: ) 829: return block 830: const filtered = block.content.filter(n => { 831: if (toRemove > 0 && isMedia(n)) { 832: toRemove-- 833: return false 834: } 835: return true 836: }) 837: return filtered.length === block.content.length 838: ? block 839: : { ...block, content: filtered } 840: }) 841: .filter(block => { 842: if (toRemove > 0 && isMedia(block)) { 843: toRemove-- 844: return false 845: } 846: return true 847: }) 848: return before === toRemove 849: ? msg 850: : { 851: ...msg, 852: message: { ...msg.message, content: stripped }, 853: } 854: }) as (UserMessage | AssistantMessage)[] 855: } 856: async function* queryModel( 857: messages: Message[], 858: systemPrompt: SystemPrompt, 859: thinkingConfig: ThinkingConfig, 860: tools: Tools, 861: signal: AbortSignal, 862: options: Options, 863: ): AsyncGenerator< 864: StreamEvent | AssistantMessage | SystemAPIErrorMessage, 865: void 866: > { 867: if ( 868: !isClaudeAISubscriber() && 869: isNonCustomOpusModel(options.model) && 870: ( 871: await getDynamicConfig_BLOCKS_ON_INIT<{ activated: boolean }>( 872: 'tengu-off-switch', 873: { 874: activated: false, 875: }, 876: ) 877: ).activated 878: ) { 879: logEvent('tengu_off_switch_query', {}) 880: yield getAssistantMessageFromError( 881: new Error(CUSTOM_OFF_SWITCH_MESSAGE), 882: options.model, 883: ) 884: return 885: } 886: const previousRequestId = getPreviousRequestIdFromMessages(messages) 887: const resolvedModel = 888: getAPIProvider() === 'bedrock' && 889: options.model.includes('application-inference-profile') 890: ? ((await getInferenceProfileBackingModel(options.model)) ?? 891: options.model) 892: : options.model 893: queryCheckpoint('query_tool_schema_build_start') 894: const isAgenticQuery = 895: options.querySource.startsWith('repl_main_thread') || 896: options.querySource.startsWith('agent:') || 897: options.querySource === 'sdk' || 898: options.querySource === 'hook_agent' || 899: options.querySource === 'verification_agent' 900: const betas = getMergedBetas(options.model, { isAgenticQuery }) 901: if (isAdvisorEnabled()) { 902: betas.push(ADVISOR_BETA_HEADER) 903: } 904: let advisorModel: string | undefined 905: if (isAgenticQuery && isAdvisorEnabled()) { 906: let advisorOption = options.advisorModel 907: const advisorExperiment = getExperimentAdvisorModels() 908: if (advisorExperiment !== undefined) { 909: if ( 910: normalizeModelStringForAPI(advisorExperiment.baseModel) === 911: normalizeModelStringForAPI(options.model) 912: ) { 913: advisorOption = advisorExperiment.advisorModel 914: } 915: } 916: if (advisorOption) { 917: const normalizedAdvisorModel = normalizeModelStringForAPI( 918: parseUserSpecifiedModel(advisorOption), 919: ) 920: if (!modelSupportsAdvisor(options.model)) { 921: logForDebugging( 922: `[AdvisorTool] Skipping advisor - base model ${options.model} does not support advisor`, 923: ) 924: } else if (!isValidAdvisorModel(normalizedAdvisorModel)) { 925: logForDebugging( 926: `[AdvisorTool] Skipping advisor - ${normalizedAdvisorModel} is not a valid advisor model`, 927: ) 928: } else { 929: advisorModel = normalizedAdvisorModel 930: logForDebugging( 931: `[AdvisorTool] Server-side tool enabled with ${advisorModel} as the advisor model`, 932: ) 933: } 934: } 935: } 936: let useToolSearch = await isToolSearchEnabled( 937: options.model, 938: tools, 939: options.getToolPermissionContext, 940: options.agents, 941: 'query', 942: ) 943: const deferredToolNames = new Set<string>() 944: if (useToolSearch) { 945: for (const t of tools) { 946: if (isDeferredTool(t)) deferredToolNames.add(t.name) 947: } 948: } 949: if ( 950: useToolSearch && 951: deferredToolNames.size === 0 && 952: !options.hasPendingMcpServers 953: ) { 954: logForDebugging( 955: 'Tool search disabled: no deferred tools available to search', 956: ) 957: useToolSearch = false 958: } 959: let filteredTools: Tools 960: if (useToolSearch) { 961: const discoveredToolNames = extractDiscoveredToolNames(messages) 962: filteredTools = tools.filter(tool => { 963: if (!deferredToolNames.has(tool.name)) return true 964: if (toolMatchesName(tool, TOOL_SEARCH_TOOL_NAME)) return true 965: return discoveredToolNames.has(tool.name) 966: }) 967: } else { 968: filteredTools = tools.filter( 969: t => !toolMatchesName(t, TOOL_SEARCH_TOOL_NAME), 970: ) 971: } 972: const toolSearchHeader = useToolSearch ? getToolSearchBetaHeader() : null 973: if (toolSearchHeader && getAPIProvider() !== 'bedrock') { 974: if (!betas.includes(toolSearchHeader)) { 975: betas.push(toolSearchHeader) 976: } 977: } 978: let cachedMCEnabled = false 979: let cacheEditingBetaHeader = '' 980: if (feature('CACHED_MICROCOMPACT')) { 981: const { 982: isCachedMicrocompactEnabled, 983: isModelSupportedForCacheEditing, 984: getCachedMCConfig, 985: } = await import('../compact/cachedMicrocompact.js') 986: const betas = await import('src/constants/betas.js') 987: cacheEditingBetaHeader = betas.CACHE_EDITING_BETA_HEADER 988: const featureEnabled = isCachedMicrocompactEnabled() 989: const modelSupported = isModelSupportedForCacheEditing(options.model) 990: cachedMCEnabled = featureEnabled && modelSupported 991: const config = getCachedMCConfig() 992: logForDebugging( 993: `Cached MC gate: enabled=${featureEnabled} modelSupported=${modelSupported} model=${options.model} supportedModels=${jsonStringify(config.supportedModels)}`, 994: ) 995: } 996: const useGlobalCacheFeature = shouldUseGlobalCacheScope() 997: const willDefer = (t: Tool) => 998: useToolSearch && (deferredToolNames.has(t.name) || shouldDeferLspTool(t)) 999: const needsToolBasedCacheMarker = 1000: useGlobalCacheFeature && 1001: filteredTools.some(t => t.isMcp === true && !willDefer(t)) 1002: if ( 1003: useGlobalCacheFeature && 1004: !betas.includes(PROMPT_CACHING_SCOPE_BETA_HEADER) 1005: ) { 1006: betas.push(PROMPT_CACHING_SCOPE_BETA_HEADER) 1007: } 1008: const globalCacheStrategy: GlobalCacheStrategy = useGlobalCacheFeature 1009: ? needsToolBasedCacheMarker 1010: ? 'none' 1011: : 'system_prompt' 1012: : 'none' 1013: const toolSchemas = await Promise.all( 1014: filteredTools.map(tool => 1015: toolToAPISchema(tool, { 1016: getToolPermissionContext: options.getToolPermissionContext, 1017: tools, 1018: agents: options.agents, 1019: allowedAgentTypes: options.allowedAgentTypes, 1020: model: options.model, 1021: deferLoading: willDefer(tool), 1022: }), 1023: ), 1024: ) 1025: if (useToolSearch) { 1026: const includedDeferredTools = count(filteredTools, t => 1027: deferredToolNames.has(t.name), 1028: ) 1029: logForDebugging( 1030: `Dynamic tool loading: ${includedDeferredTools}/${deferredToolNames.size} deferred tools included`, 1031: ) 1032: } 1033: queryCheckpoint('query_tool_schema_build_end') 1034: logEvent('tengu_api_before_normalize', { 1035: preNormalizedMessageCount: messages.length, 1036: }) 1037: queryCheckpoint('query_message_normalization_start') 1038: let messagesForAPI = normalizeMessagesForAPI(messages, filteredTools) 1039: queryCheckpoint('query_message_normalization_end') 1040: if (!useToolSearch) { 1041: messagesForAPI = messagesForAPI.map(msg => { 1042: switch (msg.type) { 1043: case 'user': 1044: return stripToolReferenceBlocksFromUserMessage(msg) 1045: case 'assistant': 1046: return stripCallerFieldFromAssistantMessage(msg) 1047: default: 1048: return msg 1049: } 1050: }) 1051: } 1052: messagesForAPI = ensureToolResultPairing(messagesForAPI) 1053: if (!betas.includes(ADVISOR_BETA_HEADER)) { 1054: messagesForAPI = stripAdvisorBlocks(messagesForAPI) 1055: } 1056: messagesForAPI = stripExcessMediaItems( 1057: messagesForAPI, 1058: API_MAX_MEDIA_PER_REQUEST, 1059: ) 1060: logEvent('tengu_api_after_normalize', { 1061: postNormalizedMessageCount: messagesForAPI.length, 1062: }) 1063: const fingerprint = computeFingerprintFromMessages(messagesForAPI) 1064: if (useToolSearch && !isDeferredToolsDeltaEnabled()) { 1065: const deferredToolList = tools 1066: .filter(t => deferredToolNames.has(t.name)) 1067: .map(formatDeferredToolLine) 1068: .sort() 1069: .join('\n') 1070: if (deferredToolList) { 1071: messagesForAPI = [ 1072: createUserMessage({ 1073: content: `<available-deferred-tools>\n${deferredToolList}\n</available-deferred-tools>`, 1074: isMeta: true, 1075: }), 1076: ...messagesForAPI, 1077: ] 1078: } 1079: } 1080: const hasChromeTools = filteredTools.some(t => 1081: isToolFromMcpServer(t.name, CLAUDE_IN_CHROME_MCP_SERVER_NAME), 1082: ) 1083: const injectChromeHere = 1084: useToolSearch && hasChromeTools && !isMcpInstructionsDeltaEnabled() 1085: systemPrompt = asSystemPrompt( 1086: [ 1087: getAttributionHeader(fingerprint), 1088: getCLISyspromptPrefix({ 1089: isNonInteractive: options.isNonInteractiveSession, 1090: hasAppendSystemPrompt: options.hasAppendSystemPrompt, 1091: }), 1092: ...systemPrompt, 1093: ...(advisorModel ? [ADVISOR_TOOL_INSTRUCTIONS] : []), 1094: ...(injectChromeHere ? [CHROME_TOOL_SEARCH_INSTRUCTIONS] : []), 1095: ].filter(Boolean), 1096: ) 1097: logAPIPrefix(systemPrompt) 1098: const enablePromptCaching = 1099: options.enablePromptCaching ?? getPromptCachingEnabled(options.model) 1100: const system = buildSystemPromptBlocks(systemPrompt, enablePromptCaching, { 1101: skipGlobalCacheForSystemPrompt: needsToolBasedCacheMarker, 1102: querySource: options.querySource, 1103: }) 1104: const useBetas = betas.length > 0 1105: const extraToolSchemas = [...(options.extraToolSchemas ?? [])] 1106: if (advisorModel) { 1107: extraToolSchemas.push({ 1108: type: 'advisor_20260301', 1109: name: 'advisor', 1110: model: advisorModel, 1111: } as unknown as BetaToolUnion) 1112: } 1113: const allTools = [...toolSchemas, ...extraToolSchemas] 1114: const isFastMode = 1115: isFastModeEnabled() && 1116: isFastModeAvailable() && 1117: !isFastModeCooldown() && 1118: isFastModeSupportedByModel(options.model) && 1119: !!options.fastMode 1120: let afkHeaderLatched = getAfkModeHeaderLatched() === true 1121: if (feature('TRANSCRIPT_CLASSIFIER')) { 1122: if ( 1123: !afkHeaderLatched && 1124: isAgenticQuery && 1125: shouldIncludeFirstPartyOnlyBetas() && 1126: (autoModeStateModule?.isAutoModeActive() ?? false) 1127: ) { 1128: afkHeaderLatched = true 1129: setAfkModeHeaderLatched(true) 1130: } 1131: } 1132: let fastModeHeaderLatched = getFastModeHeaderLatched() === true 1133: if (!fastModeHeaderLatched && isFastMode) { 1134: fastModeHeaderLatched = true 1135: setFastModeHeaderLatched(true) 1136: } 1137: let cacheEditingHeaderLatched = getCacheEditingHeaderLatched() === true 1138: if (feature('CACHED_MICROCOMPACT')) { 1139: if ( 1140: !cacheEditingHeaderLatched && 1141: cachedMCEnabled && 1142: getAPIProvider() === 'firstParty' && 1143: options.querySource === 'repl_main_thread' 1144: ) { 1145: cacheEditingHeaderLatched = true 1146: setCacheEditingHeaderLatched(true) 1147: } 1148: } 1149: let thinkingClearLatched = getThinkingClearLatched() === true 1150: if (!thinkingClearLatched && isAgenticQuery) { 1151: const lastCompletion = getLastApiCompletionTimestamp() 1152: if ( 1153: lastCompletion !== null && 1154: Date.now() - lastCompletion > CACHE_TTL_1HOUR_MS 1155: ) { 1156: thinkingClearLatched = true 1157: setThinkingClearLatched(true) 1158: } 1159: } 1160: const effort = resolveAppliedEffort(options.model, options.effortValue) 1161: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 1162: const toolsForCacheDetection = allTools.filter( 1163: t => !('defer_loading' in t && t.defer_loading), 1164: ) 1165: recordPromptState({ 1166: system, 1167: toolSchemas: toolsForCacheDetection, 1168: querySource: options.querySource, 1169: model: options.model, 1170: agentId: options.agentId, 1171: fastMode: fastModeHeaderLatched, 1172: globalCacheStrategy, 1173: betas, 1174: autoModeActive: afkHeaderLatched, 1175: isUsingOverage: currentLimits.isUsingOverage ?? false, 1176: cachedMCEnabled: cacheEditingHeaderLatched, 1177: effortValue: effort, 1178: extraBodyParams: getExtraBodyParams(), 1179: }) 1180: } 1181: const newContext: LLMRequestNewContext | undefined = isBetaTracingEnabled() 1182: ? { 1183: systemPrompt: systemPrompt.join('\n\n'), 1184: querySource: options.querySource, 1185: tools: jsonStringify(allTools), 1186: } 1187: : undefined 1188: const llmSpan = startLLMRequestSpan( 1189: options.model, 1190: newContext, 1191: messagesForAPI, 1192: isFastMode, 1193: ) 1194: const startIncludingRetries = Date.now() 1195: let start = Date.now() 1196: let attemptNumber = 0 1197: const attemptStartTimes: number[] = [] 1198: let stream: Stream<BetaRawMessageStreamEvent> | undefined = undefined 1199: let streamRequestId: string | null | undefined = undefined 1200: let clientRequestId: string | undefined = undefined 1201: let streamResponse: Response | undefined = undefined 1202: function releaseStreamResources(): void { 1203: cleanupStream(stream) 1204: stream = undefined 1205: if (streamResponse) { 1206: streamResponse.body?.cancel().catch(() => {}) 1207: streamResponse = undefined 1208: } 1209: } 1210: const consumedCacheEdits = cachedMCEnabled ? consumePendingCacheEdits() : null 1211: const consumedPinnedEdits = cachedMCEnabled ? getPinnedCacheEdits() : [] 1212: let lastRequestBetas: string[] | undefined 1213: const paramsFromContext = (retryContext: RetryContext) => { 1214: const betasParams = [...betas] 1215: if ( 1216: !betasParams.includes(CONTEXT_1M_BETA_HEADER) && 1217: getSonnet1mExpTreatmentEnabled(retryContext.model) 1218: ) { 1219: betasParams.push(CONTEXT_1M_BETA_HEADER) 1220: } 1221: const bedrockBetas = 1222: getAPIProvider() === 'bedrock' 1223: ? [ 1224: ...getBedrockExtraBodyParamsBetas(retryContext.model), 1225: ...(toolSearchHeader ? [toolSearchHeader] : []), 1226: ] 1227: : [] 1228: const extraBodyParams = getExtraBodyParams(bedrockBetas) 1229: const outputConfig: BetaOutputConfig = { 1230: ...((extraBodyParams.output_config as BetaOutputConfig) ?? {}), 1231: } 1232: configureEffortParams( 1233: effort, 1234: outputConfig, 1235: extraBodyParams, 1236: betasParams, 1237: options.model, 1238: ) 1239: configureTaskBudgetParams( 1240: options.taskBudget, 1241: outputConfig as BetaOutputConfig & { task_budget?: TaskBudgetParam }, 1242: betasParams, 1243: ) 1244: if (options.outputFormat && !('format' in outputConfig)) { 1245: outputConfig.format = options.outputFormat as BetaJSONOutputFormat 1246: if ( 1247: modelSupportsStructuredOutputs(options.model) && 1248: !betasParams.includes(STRUCTURED_OUTPUTS_BETA_HEADER) 1249: ) { 1250: betasParams.push(STRUCTURED_OUTPUTS_BETA_HEADER) 1251: } 1252: } 1253: const maxOutputTokens = 1254: retryContext?.maxTokensOverride || 1255: options.maxOutputTokensOverride || 1256: getMaxOutputTokensForModel(options.model) 1257: const hasThinking = 1258: thinkingConfig.type !== 'disabled' && 1259: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) 1260: let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined 1261: if (hasThinking && modelSupportsThinking(options.model)) { 1262: if ( 1263: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADAPTIVE_THINKING) && 1264: modelSupportsAdaptiveThinking(options.model) 1265: ) { 1266: thinking = { 1267: type: 'adaptive', 1268: } satisfies BetaMessageStreamParams['thinking'] 1269: } else { 1270: let thinkingBudget = getMaxThinkingTokensForModel(options.model) 1271: if ( 1272: thinkingConfig.type === 'enabled' && 1273: thinkingConfig.budgetTokens !== undefined 1274: ) { 1275: thinkingBudget = thinkingConfig.budgetTokens 1276: } 1277: thinkingBudget = Math.min(maxOutputTokens - 1, thinkingBudget) 1278: thinking = { 1279: budget_tokens: thinkingBudget, 1280: type: 'enabled', 1281: } satisfies BetaMessageStreamParams['thinking'] 1282: } 1283: } 1284: const contextManagement = getAPIContextManagement({ 1285: hasThinking, 1286: isRedactThinkingActive: betasParams.includes(REDACT_THINKING_BETA_HEADER), 1287: clearAllThinking: thinkingClearLatched, 1288: }) 1289: const enablePromptCaching = 1290: options.enablePromptCaching ?? getPromptCachingEnabled(retryContext.model) 1291: let speed: BetaMessageStreamParams['speed'] 1292: const isFastModeForRetry = 1293: isFastModeEnabled() && 1294: isFastModeAvailable() && 1295: !isFastModeCooldown() && 1296: isFastModeSupportedByModel(options.model) && 1297: !!retryContext.fastMode 1298: if (isFastModeForRetry) { 1299: speed = 'fast' 1300: } 1301: if (fastModeHeaderLatched && !betasParams.includes(FAST_MODE_BETA_HEADER)) { 1302: betasParams.push(FAST_MODE_BETA_HEADER) 1303: } 1304: if (feature('TRANSCRIPT_CLASSIFIER')) { 1305: if ( 1306: afkHeaderLatched && 1307: shouldIncludeFirstPartyOnlyBetas() && 1308: isAgenticQuery && 1309: !betasParams.includes(AFK_MODE_BETA_HEADER) 1310: ) { 1311: betasParams.push(AFK_MODE_BETA_HEADER) 1312: } 1313: } 1314: const useCachedMC = 1315: cachedMCEnabled && 1316: getAPIProvider() === 'firstParty' && 1317: options.querySource === 'repl_main_thread' 1318: if ( 1319: cacheEditingHeaderLatched && 1320: getAPIProvider() === 'firstParty' && 1321: options.querySource === 'repl_main_thread' && 1322: !betasParams.includes(cacheEditingBetaHeader) 1323: ) { 1324: betasParams.push(cacheEditingBetaHeader) 1325: logForDebugging( 1326: 'Cache editing beta header enabled for cached microcompact', 1327: ) 1328: } 1329: const temperature = !hasThinking 1330: ? (options.temperatureOverride ?? 1) 1331: : undefined 1332: lastRequestBetas = betasParams 1333: return { 1334: model: normalizeModelStringForAPI(options.model), 1335: messages: addCacheBreakpoints( 1336: messagesForAPI, 1337: enablePromptCaching, 1338: options.querySource, 1339: useCachedMC, 1340: consumedCacheEdits, 1341: consumedPinnedEdits, 1342: options.skipCacheWrite, 1343: ), 1344: system, 1345: tools: allTools, 1346: tool_choice: options.toolChoice, 1347: ...(useBetas && { betas: betasParams }), 1348: metadata: getAPIMetadata(), 1349: max_tokens: maxOutputTokens, 1350: thinking, 1351: ...(temperature !== undefined && { temperature }), 1352: ...(contextManagement && 1353: useBetas && 1354: betasParams.includes(CONTEXT_MANAGEMENT_BETA_HEADER) && { 1355: context_management: contextManagement, 1356: }), 1357: ...extraBodyParams, 1358: ...(Object.keys(outputConfig).length > 0 && { 1359: output_config: outputConfig, 1360: }), 1361: ...(speed !== undefined && { speed }), 1362: } 1363: } 1364: { 1365: const queryParams = paramsFromContext({ 1366: model: options.model, 1367: thinkingConfig, 1368: }) 1369: const logMessagesLength = queryParams.messages.length 1370: const logBetas = useBetas ? (queryParams.betas ?? []) : [] 1371: const logThinkingType = queryParams.thinking?.type ?? 'disabled' 1372: const logEffortValue = queryParams.output_config?.effort 1373: void options.getToolPermissionContext().then(permissionContext => { 1374: logAPIQuery({ 1375: model: options.model, 1376: messagesLength: logMessagesLength, 1377: temperature: options.temperatureOverride ?? 1, 1378: betas: logBetas, 1379: permissionMode: permissionContext.mode, 1380: querySource: options.querySource, 1381: queryTracking: options.queryTracking, 1382: thinkingType: logThinkingType, 1383: effortValue: logEffortValue, 1384: fastMode: isFastMode, 1385: previousRequestId, 1386: }) 1387: }) 1388: } 1389: const newMessages: AssistantMessage[] = [] 1390: let ttftMs = 0 1391: let partialMessage: BetaMessage | undefined = undefined 1392: const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = [] 1393: let usage: NonNullableUsage = EMPTY_USAGE 1394: let costUSD = 0 1395: let stopReason: BetaStopReason | null = null 1396: let didFallBackToNonStreaming = false 1397: let fallbackMessage: AssistantMessage | undefined 1398: let maxOutputTokens = 0 1399: let responseHeaders: globalThis.Headers | undefined = undefined 1400: let research: unknown = undefined 1401: let isFastModeRequest = isFastMode 1402: let isAdvisorInProgress = false 1403: try { 1404: queryCheckpoint('query_client_creation_start') 1405: const generator = withRetry( 1406: () => 1407: getAnthropicClient({ 1408: maxRetries: 0, 1409: model: options.model, 1410: fetchOverride: options.fetchOverride, 1411: source: options.querySource, 1412: }), 1413: async (anthropic, attempt, context) => { 1414: attemptNumber = attempt 1415: isFastModeRequest = context.fastMode ?? false 1416: start = Date.now() 1417: attemptStartTimes.push(start) 1418: queryCheckpoint('query_client_creation_end') 1419: const params = paramsFromContext(context) 1420: captureAPIRequest(params, options.querySource) 1421: maxOutputTokens = params.max_tokens 1422: queryCheckpoint('query_api_request_sent') 1423: if (!options.agentId) { 1424: headlessProfilerCheckpoint('api_request_sent') 1425: } 1426: clientRequestId = 1427: getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl() 1428: ? randomUUID() 1429: : undefined 1430: const result = await anthropic.beta.messages 1431: .create( 1432: { ...params, stream: true }, 1433: { 1434: signal, 1435: ...(clientRequestId && { 1436: headers: { [CLIENT_REQUEST_ID_HEADER]: clientRequestId }, 1437: }), 1438: }, 1439: ) 1440: .withResponse() 1441: queryCheckpoint('query_response_headers_received') 1442: streamRequestId = result.request_id 1443: streamResponse = result.response 1444: return result.data 1445: }, 1446: { 1447: model: options.model, 1448: fallbackModel: options.fallbackModel, 1449: thinkingConfig, 1450: ...(isFastModeEnabled() ? { fastMode: isFastMode } : false), 1451: signal, 1452: querySource: options.querySource, 1453: }, 1454: ) 1455: let e 1456: do { 1457: e = await generator.next() 1458: if (!('controller' in e.value)) { 1459: yield e.value 1460: } 1461: } while (!e.done) 1462: stream = e.value as Stream<BetaRawMessageStreamEvent> 1463: newMessages.length = 0 1464: ttftMs = 0 1465: partialMessage = undefined 1466: contentBlocks.length = 0 1467: usage = EMPTY_USAGE 1468: stopReason = null 1469: isAdvisorInProgress = false 1470: const streamWatchdogEnabled = isEnvTruthy( 1471: process.env.CLAUDE_ENABLE_STREAM_WATCHDOG, 1472: ) 1473: const STREAM_IDLE_TIMEOUT_MS = 1474: parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000 1475: const STREAM_IDLE_WARNING_MS = STREAM_IDLE_TIMEOUT_MS / 2 1476: let streamIdleAborted = false 1477: // performance.now() snapshot when watchdog fires, for measuring abort propagation delay 1478: let streamWatchdogFiredAt: number | null = null 1479: let streamIdleWarningTimer: ReturnType<typeof setTimeout> | null = null 1480: let streamIdleTimer: ReturnType<typeof setTimeout> | null = null 1481: function clearStreamIdleTimers(): void { 1482: if (streamIdleWarningTimer !== null) { 1483: clearTimeout(streamIdleWarningTimer) 1484: streamIdleWarningTimer = null 1485: } 1486: if (streamIdleTimer !== null) { 1487: clearTimeout(streamIdleTimer) 1488: streamIdleTimer = null 1489: } 1490: } 1491: function resetStreamIdleTimer(): void { 1492: clearStreamIdleTimers() 1493: if (!streamWatchdogEnabled) { 1494: return 1495: } 1496: streamIdleWarningTimer = setTimeout( 1497: warnMs => { 1498: logForDebugging( 1499: `Streaming idle warning: no chunks received for ${warnMs / 1000}s`, 1500: { level: 'warn' }, 1501: ) 1502: logForDiagnosticsNoPII('warn', 'cli_streaming_idle_warning') 1503: }, 1504: STREAM_IDLE_WARNING_MS, 1505: STREAM_IDLE_WARNING_MS, 1506: ) 1507: streamIdleTimer = setTimeout(() => { 1508: streamIdleAborted = true 1509: streamWatchdogFiredAt = performance.now() 1510: logForDebugging( 1511: `Streaming idle timeout: no chunks received for ${STREAM_IDLE_TIMEOUT_MS / 1000}s, aborting stream`, 1512: { level: 'error' }, 1513: ) 1514: logForDiagnosticsNoPII('error', 'cli_streaming_idle_timeout') 1515: logEvent('tengu_streaming_idle_timeout', { 1516: model: 1517: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1518: request_id: (streamRequestId ?? 1519: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1520: timeout_ms: STREAM_IDLE_TIMEOUT_MS, 1521: }) 1522: releaseStreamResources() 1523: }, STREAM_IDLE_TIMEOUT_MS) 1524: } 1525: resetStreamIdleTimer() 1526: startSessionActivity('api_call') 1527: try { 1528: let isFirstChunk = true 1529: let lastEventTime: number | null = null 1530: const STALL_THRESHOLD_MS = 30_000 1531: let totalStallTime = 0 1532: let stallCount = 0 1533: for await (const part of stream) { 1534: resetStreamIdleTimer() 1535: const now = Date.now() 1536: if (lastEventTime !== null) { 1537: const timeSinceLastEvent = now - lastEventTime 1538: if (timeSinceLastEvent > STALL_THRESHOLD_MS) { 1539: stallCount++ 1540: totalStallTime += timeSinceLastEvent 1541: logForDebugging( 1542: `Streaming stall detected: ${(timeSinceLastEvent / 1000).toFixed(1)}s gap between events (stall #${stallCount})`, 1543: { level: 'warn' }, 1544: ) 1545: logEvent('tengu_streaming_stall', { 1546: stall_duration_ms: timeSinceLastEvent, 1547: stall_count: stallCount, 1548: total_stall_time_ms: totalStallTime, 1549: event_type: 1550: part.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1551: model: 1552: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1553: request_id: (streamRequestId ?? 1554: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1555: }) 1556: } 1557: } 1558: lastEventTime = now 1559: if (isFirstChunk) { 1560: logForDebugging('Stream started - received first chunk') 1561: queryCheckpoint('query_first_chunk_received') 1562: if (!options.agentId) { 1563: headlessProfilerCheckpoint('first_chunk') 1564: } 1565: endQueryProfile() 1566: isFirstChunk = false 1567: } 1568: switch (part.type) { 1569: case 'message_start': { 1570: partialMessage = part.message 1571: ttftMs = Date.now() - start 1572: usage = updateUsage(usage, part.message?.usage) 1573: if ( 1574: process.env.USER_TYPE === 'ant' && 1575: 'research' in (part.message as unknown as Record<string, unknown>) 1576: ) { 1577: research = (part.message as unknown as Record<string, unknown>) 1578: .research 1579: } 1580: break 1581: } 1582: case 'content_block_start': 1583: switch (part.content_block.type) { 1584: case 'tool_use': 1585: contentBlocks[part.index] = { 1586: ...part.content_block, 1587: input: '', 1588: } 1589: break 1590: case 'server_tool_use': 1591: contentBlocks[part.index] = { 1592: ...part.content_block, 1593: input: '' as unknown as { [key: string]: unknown }, 1594: } 1595: if ((part.content_block.name as string) === 'advisor') { 1596: isAdvisorInProgress = true 1597: logForDebugging(`[AdvisorTool] Advisor tool called`) 1598: logEvent('tengu_advisor_tool_call', { 1599: model: 1600: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1601: advisor_model: (advisorModel ?? 1602: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1603: }) 1604: } 1605: break 1606: case 'text': 1607: contentBlocks[part.index] = { 1608: ...part.content_block, 1609: text: '', 1610: } 1611: break 1612: case 'thinking': 1613: contentBlocks[part.index] = { 1614: ...part.content_block, 1615: thinking: '', 1616: // initialize signature to ensure field exists even if signature_delta never arrives 1617: signature: '', 1618: } 1619: break 1620: default: 1621: // even more awkwardly, the sdk mutates the contents of text blocks 1622: // as it works. we want the blocks to be immutable, so that we can 1623: // accumulate state ourselves. 1624: contentBlocks[part.index] = { ...part.content_block } 1625: if ( 1626: (part.content_block.type as string) === 'advisor_tool_result' 1627: ) { 1628: isAdvisorInProgress = false 1629: logForDebugging(`[AdvisorTool] Advisor tool result received`) 1630: } 1631: break 1632: } 1633: break 1634: case 'content_block_delta': { 1635: const contentBlock = contentBlocks[part.index] 1636: const delta = part.delta as typeof part.delta | ConnectorTextDelta 1637: if (!contentBlock) { 1638: logEvent('tengu_streaming_error', { 1639: error_type: 1640: 'content_block_not_found_delta' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1641: part_type: 1642: part.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1643: part_index: part.index, 1644: }) 1645: throw new RangeError('Content block not found') 1646: } 1647: if ( 1648: feature('CONNECTOR_TEXT') && 1649: delta.type === 'connector_text_delta' 1650: ) { 1651: if (contentBlock.type !== 'connector_text') { 1652: logEvent('tengu_streaming_error', { 1653: error_type: 1654: 'content_block_type_mismatch_connector_text' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1655: expected_type: 1656: 'connector_text' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1657: actual_type: 1658: contentBlock.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1659: }) 1660: throw new Error('Content block is not a connector_text block') 1661: } 1662: contentBlock.connector_text += delta.connector_text 1663: } else { 1664: switch (delta.type) { 1665: case 'citations_delta': 1666: break 1667: case 'input_json_delta': 1668: if ( 1669: contentBlock.type !== 'tool_use' && 1670: contentBlock.type !== 'server_tool_use' 1671: ) { 1672: logEvent('tengu_streaming_error', { 1673: error_type: 1674: 'content_block_type_mismatch_input_json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1675: expected_type: 1676: 'tool_use' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1677: actual_type: 1678: contentBlock.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1679: }) 1680: throw new Error('Content block is not a input_json block') 1681: } 1682: if (typeof contentBlock.input !== 'string') { 1683: logEvent('tengu_streaming_error', { 1684: error_type: 1685: 'content_block_input_not_string' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1686: input_type: 1687: typeof contentBlock.input as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1688: }) 1689: throw new Error('Content block input is not a string') 1690: } 1691: contentBlock.input += delta.partial_json 1692: break 1693: case 'text_delta': 1694: if (contentBlock.type !== 'text') { 1695: logEvent('tengu_streaming_error', { 1696: error_type: 1697: 'content_block_type_mismatch_text' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1698: expected_type: 1699: 'text' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1700: actual_type: 1701: contentBlock.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1702: }) 1703: throw new Error('Content block is not a text block') 1704: } 1705: contentBlock.text += delta.text 1706: break 1707: case 'signature_delta': 1708: if ( 1709: feature('CONNECTOR_TEXT') && 1710: contentBlock.type === 'connector_text' 1711: ) { 1712: contentBlock.signature = delta.signature 1713: break 1714: } 1715: if (contentBlock.type !== 'thinking') { 1716: logEvent('tengu_streaming_error', { 1717: error_type: 1718: 'content_block_type_mismatch_thinking_signature' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1719: expected_type: 1720: 'thinking' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1721: actual_type: 1722: contentBlock.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1723: }) 1724: throw new Error('Content block is not a thinking block') 1725: } 1726: contentBlock.signature = delta.signature 1727: break 1728: case 'thinking_delta': 1729: if (contentBlock.type !== 'thinking') { 1730: logEvent('tengu_streaming_error', { 1731: error_type: 1732: 'content_block_type_mismatch_thinking_delta' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1733: expected_type: 1734: 'thinking' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1735: actual_type: 1736: contentBlock.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1737: }) 1738: throw new Error('Content block is not a thinking block') 1739: } 1740: contentBlock.thinking += delta.thinking 1741: break 1742: } 1743: } 1744: if (process.env.USER_TYPE === 'ant' && 'research' in part) { 1745: research = (part as { research: unknown }).research 1746: } 1747: break 1748: } 1749: case 'content_block_stop': { 1750: const contentBlock = contentBlocks[part.index] 1751: if (!contentBlock) { 1752: logEvent('tengu_streaming_error', { 1753: error_type: 1754: 'content_block_not_found_stop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1755: part_type: 1756: part.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1757: part_index: part.index, 1758: }) 1759: throw new RangeError('Content block not found') 1760: } 1761: if (!partialMessage) { 1762: logEvent('tengu_streaming_error', { 1763: error_type: 1764: 'partial_message_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1765: part_type: 1766: part.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1767: }) 1768: throw new Error('Message not found') 1769: } 1770: const m: AssistantMessage = { 1771: message: { 1772: ...partialMessage, 1773: content: normalizeContentFromAPI( 1774: [contentBlock] as BetaContentBlock[], 1775: tools, 1776: options.agentId, 1777: ), 1778: }, 1779: requestId: streamRequestId ?? undefined, 1780: type: 'assistant', 1781: uuid: randomUUID(), 1782: timestamp: new Date().toISOString(), 1783: ...(process.env.USER_TYPE === 'ant' && 1784: research !== undefined && { research }), 1785: ...(advisorModel && { advisorModel }), 1786: } 1787: newMessages.push(m) 1788: yield m 1789: break 1790: } 1791: case 'message_delta': { 1792: usage = updateUsage(usage, part.usage) 1793: if ( 1794: process.env.USER_TYPE === 'ant' && 1795: 'research' in (part as unknown as Record<string, unknown>) 1796: ) { 1797: research = (part as unknown as Record<string, unknown>).research 1798: for (const msg of newMessages) { 1799: msg.research = research 1800: } 1801: } 1802: stopReason = part.delta.stop_reason 1803: const lastMsg = newMessages.at(-1) 1804: if (lastMsg) { 1805: lastMsg.message.usage = usage 1806: lastMsg.message.stop_reason = stopReason 1807: } 1808: const costUSDForPart = calculateUSDCost(resolvedModel, usage) 1809: costUSD += addToTotalSessionCost( 1810: costUSDForPart, 1811: usage, 1812: options.model, 1813: ) 1814: const refusalMessage = getErrorMessageIfRefusal( 1815: part.delta.stop_reason, 1816: options.model, 1817: ) 1818: if (refusalMessage) { 1819: yield refusalMessage 1820: } 1821: if (stopReason === 'max_tokens') { 1822: logEvent('tengu_max_tokens_reached', { 1823: max_tokens: maxOutputTokens, 1824: }) 1825: yield createAssistantAPIErrorMessage({ 1826: content: `${API_ERROR_MESSAGE_PREFIX}: Claude's response exceeded the ${ 1827: maxOutputTokens 1828: } output token maximum. To configure this behavior, set the CLAUDE_CODE_MAX_OUTPUT_TOKENS environment variable.`, 1829: apiError: 'max_output_tokens', 1830: error: 'max_output_tokens', 1831: }) 1832: } 1833: if (stopReason === 'model_context_window_exceeded') { 1834: logEvent('tengu_context_window_exceeded', { 1835: max_tokens: maxOutputTokens, 1836: output_tokens: usage.output_tokens, 1837: }) 1838: yield createAssistantAPIErrorMessage({ 1839: content: `${API_ERROR_MESSAGE_PREFIX}: The model has reached its context window limit.`, 1840: apiError: 'max_output_tokens', 1841: error: 'max_output_tokens', 1842: }) 1843: } 1844: break 1845: } 1846: case 'message_stop': 1847: break 1848: } 1849: yield { 1850: type: 'stream_event', 1851: event: part, 1852: ...(part.type === 'message_start' ? { ttftMs } : undefined), 1853: } 1854: } 1855: clearStreamIdleTimers() 1856: if (streamIdleAborted) { 1857: const exitDelayMs = 1858: streamWatchdogFiredAt !== null 1859: ? Math.round(performance.now() - streamWatchdogFiredAt) 1860: : -1 1861: logForDiagnosticsNoPII( 1862: 'info', 1863: 'cli_stream_loop_exited_after_watchdog_clean', 1864: ) 1865: logEvent('tengu_stream_loop_exited_after_watchdog', { 1866: request_id: (streamRequestId ?? 1867: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1868: exit_delay_ms: exitDelayMs, 1869: exit_path: 1870: 'clean' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1871: model: 1872: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1873: }) 1874: streamWatchdogFiredAt = null 1875: throw new Error('Stream idle timeout - no chunks received') 1876: } 1877: if (!partialMessage || (newMessages.length === 0 && !stopReason)) { 1878: logForDebugging( 1879: !partialMessage 1880: ? 'Stream completed without receiving message_start event - triggering non-streaming fallback' 1881: : 'Stream completed with message_start but no content blocks completed - triggering non-streaming fallback', 1882: { level: 'error' }, 1883: ) 1884: logEvent('tengu_stream_no_events', { 1885: model: 1886: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1887: request_id: (streamRequestId ?? 1888: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1889: }) 1890: throw new Error('Stream ended without receiving any events') 1891: } 1892: if (stallCount > 0) { 1893: logForDebugging( 1894: `Streaming completed with ${stallCount} stall(s), total stall time: ${(totalStallTime / 1000).toFixed(1)}s`, 1895: { level: 'warn' }, 1896: ) 1897: logEvent('tengu_streaming_stall_summary', { 1898: stall_count: stallCount, 1899: total_stall_time_ms: totalStallTime, 1900: model: 1901: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1902: request_id: (streamRequestId ?? 1903: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1904: }) 1905: } 1906: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 1907: void checkResponseForCacheBreak( 1908: options.querySource, 1909: usage.cache_read_input_tokens, 1910: usage.cache_creation_input_tokens, 1911: messages, 1912: options.agentId, 1913: streamRequestId, 1914: ) 1915: } 1916: const resp = streamResponse as unknown as Response | undefined 1917: if (resp) { 1918: extractQuotaStatusFromHeaders(resp.headers) 1919: responseHeaders = resp.headers 1920: } 1921: } catch (streamingError) { 1922: clearStreamIdleTimers() 1923: if (streamIdleAborted && streamWatchdogFiredAt !== null) { 1924: const exitDelayMs = Math.round( 1925: performance.now() - streamWatchdogFiredAt, 1926: ) 1927: logForDiagnosticsNoPII( 1928: 'info', 1929: 'cli_stream_loop_exited_after_watchdog_error', 1930: ) 1931: logEvent('tengu_stream_loop_exited_after_watchdog', { 1932: request_id: (streamRequestId ?? 1933: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1934: exit_delay_ms: exitDelayMs, 1935: exit_path: 1936: 'error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1937: error_name: 1938: streamingError instanceof Error 1939: ? (streamingError.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 1940: : ('unknown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), 1941: model: 1942: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1943: }) 1944: } 1945: if (streamingError instanceof APIUserAbortError) { 1946: if (signal.aborted) { 1947: logForDebugging( 1948: `Streaming aborted by user: ${errorMessage(streamingError)}`, 1949: ) 1950: if (isAdvisorInProgress) { 1951: logEvent('tengu_advisor_tool_interrupted', { 1952: model: 1953: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1954: advisor_model: (advisorModel ?? 1955: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1956: }) 1957: } 1958: throw streamingError 1959: } else { 1960: logForDebugging( 1961: `Streaming timeout (SDK abort): ${streamingError.message}`, 1962: { level: 'error' }, 1963: ) 1964: throw new APIConnectionTimeoutError({ message: 'Request timed out' }) 1965: } 1966: } 1967: const disableFallback = 1968: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK) || 1969: getFeatureValue_CACHED_MAY_BE_STALE( 1970: 'tengu_disable_streaming_to_non_streaming_fallback', 1971: false, 1972: ) 1973: if (disableFallback) { 1974: logForDebugging( 1975: `Error streaming (non-streaming fallback disabled): ${errorMessage(streamingError)}`, 1976: { level: 'error' }, 1977: ) 1978: logEvent('tengu_streaming_fallback_to_non_streaming', { 1979: model: 1980: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1981: error: 1982: streamingError instanceof Error 1983: ? (streamingError.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 1984: : (String( 1985: streamingError, 1986: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), 1987: attemptNumber, 1988: maxOutputTokens, 1989: thinkingType: 1990: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1991: fallback_disabled: true, 1992: request_id: (streamRequestId ?? 1993: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1994: fallback_cause: (streamIdleAborted 1995: ? 'watchdog' 1996: : 'other') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1997: }) 1998: throw streamingError 1999: } 2000: logForDebugging( 2001: `Error streaming, falling back to non-streaming mode: ${errorMessage(streamingError)}`, 2002: { level: 'error' }, 2003: ) 2004: didFallBackToNonStreaming = true 2005: if (options.onStreamingFallback) { 2006: options.onStreamingFallback() 2007: } 2008: logEvent('tengu_streaming_fallback_to_non_streaming', { 2009: model: 2010: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2011: error: 2012: streamingError instanceof Error 2013: ? (streamingError.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2014: : (String( 2015: streamingError, 2016: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), 2017: attemptNumber, 2018: maxOutputTokens, 2019: thinkingType: 2020: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2021: fallback_disabled: false, 2022: request_id: (streamRequestId ?? 2023: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2024: fallback_cause: (streamIdleAborted 2025: ? 'watchdog' 2026: : 'other') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2027: }) 2028: logForDiagnosticsNoPII('info', 'cli_nonstreaming_fallback_started') 2029: logEvent('tengu_nonstreaming_fallback_started', { 2030: request_id: (streamRequestId ?? 2031: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2032: model: 2033: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2034: fallback_cause: (streamIdleAborted 2035: ? 'watchdog' 2036: : 'other') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2037: }) 2038: const result = yield* executeNonStreamingRequest( 2039: { model: options.model, source: options.querySource }, 2040: { 2041: model: options.model, 2042: fallbackModel: options.fallbackModel, 2043: thinkingConfig, 2044: ...(isFastModeEnabled() && { fastMode: isFastMode }), 2045: signal, 2046: initialConsecutive529Errors: is529Error(streamingError) ? 1 : 0, 2047: querySource: options.querySource, 2048: }, 2049: paramsFromContext, 2050: (attempt, _startTime, tokens) => { 2051: attemptNumber = attempt 2052: maxOutputTokens = tokens 2053: }, 2054: params => captureAPIRequest(params, options.querySource), 2055: streamRequestId, 2056: ) 2057: const m: AssistantMessage = { 2058: message: { 2059: ...result, 2060: content: normalizeContentFromAPI( 2061: result.content, 2062: tools, 2063: options.agentId, 2064: ), 2065: }, 2066: requestId: streamRequestId ?? undefined, 2067: type: 'assistant', 2068: uuid: randomUUID(), 2069: timestamp: new Date().toISOString(), 2070: ...(process.env.USER_TYPE === 'ant' && 2071: research !== undefined && { 2072: research, 2073: }), 2074: ...(advisorModel && { 2075: advisorModel, 2076: }), 2077: } 2078: newMessages.push(m) 2079: fallbackMessage = m 2080: yield m 2081: } finally { 2082: clearStreamIdleTimers() 2083: } 2084: } catch (errorFromRetry) { 2085: if (errorFromRetry instanceof FallbackTriggeredError) { 2086: throw errorFromRetry 2087: } 2088: const is404StreamCreationError = 2089: !didFallBackToNonStreaming && 2090: errorFromRetry instanceof CannotRetryError && 2091: errorFromRetry.originalError instanceof APIError && 2092: errorFromRetry.originalError.status === 404 2093: if (is404StreamCreationError) { 2094: const failedRequestId = 2095: (errorFromRetry.originalError as APIError).requestID ?? 'unknown' 2096: logForDebugging( 2097: 'Streaming endpoint returned 404, falling back to non-streaming mode', 2098: { level: 'warn' }, 2099: ) 2100: didFallBackToNonStreaming = true 2101: if (options.onStreamingFallback) { 2102: options.onStreamingFallback() 2103: } 2104: logEvent('tengu_streaming_fallback_to_non_streaming', { 2105: model: 2106: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2107: error: 2108: '404_stream_creation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2109: attemptNumber, 2110: maxOutputTokens, 2111: thinkingType: 2112: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2113: request_id: 2114: failedRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2115: fallback_cause: 2116: '404_stream_creation' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2117: }) 2118: try { 2119: const result = yield* executeNonStreamingRequest( 2120: { model: options.model, source: options.querySource }, 2121: { 2122: model: options.model, 2123: fallbackModel: options.fallbackModel, 2124: thinkingConfig, 2125: ...(isFastModeEnabled() && { fastMode: isFastMode }), 2126: signal, 2127: }, 2128: paramsFromContext, 2129: (attempt, _startTime, tokens) => { 2130: attemptNumber = attempt 2131: maxOutputTokens = tokens 2132: }, 2133: params => captureAPIRequest(params, options.querySource), 2134: failedRequestId, 2135: ) 2136: const m: AssistantMessage = { 2137: message: { 2138: ...result, 2139: content: normalizeContentFromAPI( 2140: result.content, 2141: tools, 2142: options.agentId, 2143: ), 2144: }, 2145: requestId: streamRequestId ?? undefined, 2146: type: 'assistant', 2147: uuid: randomUUID(), 2148: timestamp: new Date().toISOString(), 2149: ...(process.env.USER_TYPE === 'ant' && 2150: research !== undefined && { research }), 2151: ...(advisorModel && { advisorModel }), 2152: } 2153: newMessages.push(m) 2154: fallbackMessage = m 2155: yield m 2156: } catch (fallbackError) { 2157: if (fallbackError instanceof FallbackTriggeredError) { 2158: throw fallbackError 2159: } 2160: logForDebugging( 2161: `Non-streaming fallback also failed: ${errorMessage(fallbackError)}`, 2162: { level: 'error' }, 2163: ) 2164: let error = fallbackError 2165: let errorModel = options.model 2166: if (fallbackError instanceof CannotRetryError) { 2167: error = fallbackError.originalError 2168: errorModel = fallbackError.retryContext.model 2169: } 2170: if (error instanceof APIError) { 2171: extractQuotaStatusFromError(error) 2172: } 2173: const requestId = 2174: streamRequestId || 2175: (error instanceof APIError ? error.requestID : undefined) || 2176: (error instanceof APIError 2177: ? (error.error as { request_id?: string })?.request_id 2178: : undefined) 2179: logAPIError({ 2180: error, 2181: model: errorModel, 2182: messageCount: messagesForAPI.length, 2183: messageTokens: tokenCountFromLastAPIResponse(messagesForAPI), 2184: durationMs: Date.now() - start, 2185: durationMsIncludingRetries: Date.now() - startIncludingRetries, 2186: attempt: attemptNumber, 2187: requestId, 2188: clientRequestId, 2189: didFallBackToNonStreaming, 2190: queryTracking: options.queryTracking, 2191: querySource: options.querySource, 2192: llmSpan, 2193: fastMode: isFastModeRequest, 2194: previousRequestId, 2195: }) 2196: if (error instanceof APIUserAbortError) { 2197: releaseStreamResources() 2198: return 2199: } 2200: yield getAssistantMessageFromError(error, errorModel, { 2201: messages, 2202: messagesForAPI, 2203: }) 2204: releaseStreamResources() 2205: return 2206: } 2207: } else { 2208: logForDebugging(`Error in API request: ${errorMessage(errorFromRetry)}`, { 2209: level: 'error', 2210: }) 2211: let error = errorFromRetry 2212: let errorModel = options.model 2213: if (errorFromRetry instanceof CannotRetryError) { 2214: error = errorFromRetry.originalError 2215: errorModel = errorFromRetry.retryContext.model 2216: } 2217: if (error instanceof APIError) { 2218: extractQuotaStatusFromError(error) 2219: } 2220: const requestId = 2221: streamRequestId || 2222: (error instanceof APIError ? error.requestID : undefined) || 2223: (error instanceof APIError 2224: ? (error.error as { request_id?: string })?.request_id 2225: : undefined) 2226: logAPIError({ 2227: error, 2228: model: errorModel, 2229: messageCount: messagesForAPI.length, 2230: messageTokens: tokenCountFromLastAPIResponse(messagesForAPI), 2231: durationMs: Date.now() - start, 2232: durationMsIncludingRetries: Date.now() - startIncludingRetries, 2233: attempt: attemptNumber, 2234: requestId, 2235: clientRequestId, 2236: didFallBackToNonStreaming, 2237: queryTracking: options.queryTracking, 2238: querySource: options.querySource, 2239: llmSpan, 2240: fastMode: isFastModeRequest, 2241: previousRequestId, 2242: }) 2243: if (error instanceof APIUserAbortError) { 2244: releaseStreamResources() 2245: return 2246: } 2247: yield getAssistantMessageFromError(error, errorModel, { 2248: messages, 2249: messagesForAPI, 2250: }) 2251: releaseStreamResources() 2252: return 2253: } 2254: } finally { 2255: stopSessionActivity('api_call') 2256: releaseStreamResources() 2257: if (fallbackMessage) { 2258: const fallbackUsage = fallbackMessage.message.usage 2259: usage = updateUsage(EMPTY_USAGE, fallbackUsage) 2260: stopReason = fallbackMessage.message.stop_reason 2261: const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage) 2262: costUSD += addToTotalSessionCost( 2263: fallbackCost, 2264: fallbackUsage, 2265: options.model, 2266: ) 2267: } 2268: } 2269: if (feature('CACHED_MICROCOMPACT') && cachedMCEnabled) { 2270: markToolsSentToAPIState() 2271: } 2272: if ( 2273: streamRequestId && 2274: !getAgentContext() && 2275: (options.querySource.startsWith('repl_main_thread') || 2276: options.querySource === 'sdk') 2277: ) { 2278: setLastMainRequestId(streamRequestId) 2279: } 2280: const logMessageCount = messagesForAPI.length 2281: const logMessageTokens = tokenCountFromLastAPIResponse(messagesForAPI) 2282: void options.getToolPermissionContext().then(permissionContext => { 2283: logAPISuccessAndDuration({ 2284: model: 2285: newMessages[0]?.message.model ?? partialMessage?.model ?? options.model, 2286: preNormalizedModel: options.model, 2287: usage, 2288: start, 2289: startIncludingRetries, 2290: attempt: attemptNumber, 2291: messageCount: logMessageCount, 2292: messageTokens: logMessageTokens, 2293: requestId: streamRequestId ?? null, 2294: stopReason, 2295: ttftMs, 2296: didFallBackToNonStreaming, 2297: querySource: options.querySource, 2298: headers: responseHeaders, 2299: costUSD, 2300: queryTracking: options.queryTracking, 2301: permissionMode: permissionContext.mode, 2302: newMessages, 2303: llmSpan, 2304: globalCacheStrategy, 2305: requestSetupMs: start - startIncludingRetries, 2306: attemptStartTimes, 2307: fastMode: isFastModeRequest, 2308: previousRequestId, 2309: betas: lastRequestBetas, 2310: }) 2311: }) 2312: releaseStreamResources() 2313: } 2314: export function cleanupStream( 2315: stream: Stream<BetaRawMessageStreamEvent> | undefined, 2316: ): void { 2317: if (!stream) { 2318: return 2319: } 2320: try { 2321: if (!stream.controller.signal.aborted) { 2322: stream.controller.abort() 2323: } 2324: } catch { 2325: } 2326: } 2327: export function updateUsage( 2328: usage: Readonly<NonNullableUsage>, 2329: partUsage: BetaMessageDeltaUsage | undefined, 2330: ): NonNullableUsage { 2331: if (!partUsage) { 2332: return { ...usage } 2333: } 2334: return { 2335: input_tokens: 2336: partUsage.input_tokens !== null && partUsage.input_tokens > 0 2337: ? partUsage.input_tokens 2338: : usage.input_tokens, 2339: cache_creation_input_tokens: 2340: partUsage.cache_creation_input_tokens !== null && 2341: partUsage.cache_creation_input_tokens > 0 2342: ? partUsage.cache_creation_input_tokens 2343: : usage.cache_creation_input_tokens, 2344: cache_read_input_tokens: 2345: partUsage.cache_read_input_tokens !== null && 2346: partUsage.cache_read_input_tokens > 0 2347: ? partUsage.cache_read_input_tokens 2348: : usage.cache_read_input_tokens, 2349: output_tokens: partUsage.output_tokens ?? usage.output_tokens, 2350: server_tool_use: { 2351: web_search_requests: 2352: partUsage.server_tool_use?.web_search_requests ?? 2353: usage.server_tool_use.web_search_requests, 2354: web_fetch_requests: 2355: partUsage.server_tool_use?.web_fetch_requests ?? 2356: usage.server_tool_use.web_fetch_requests, 2357: }, 2358: service_tier: usage.service_tier, 2359: cache_creation: { 2360: ephemeral_1h_input_tokens: 2361: (partUsage as BetaUsage).cache_creation?.ephemeral_1h_input_tokens ?? 2362: usage.cache_creation.ephemeral_1h_input_tokens, 2363: ephemeral_5m_input_tokens: 2364: (partUsage as BetaUsage).cache_creation?.ephemeral_5m_input_tokens ?? 2365: usage.cache_creation.ephemeral_5m_input_tokens, 2366: }, 2367: ...(feature('CACHED_MICROCOMPACT') 2368: ? { 2369: cache_deleted_input_tokens: 2370: (partUsage as unknown as { cache_deleted_input_tokens?: number }) 2371: .cache_deleted_input_tokens != null && 2372: (partUsage as unknown as { cache_deleted_input_tokens: number }) 2373: .cache_deleted_input_tokens > 0 2374: ? (partUsage as unknown as { cache_deleted_input_tokens: number }) 2375: .cache_deleted_input_tokens 2376: : ((usage as unknown as { cache_deleted_input_tokens?: number }) 2377: .cache_deleted_input_tokens ?? 0), 2378: } 2379: : {}), 2380: inference_geo: usage.inference_geo, 2381: iterations: partUsage.iterations ?? usage.iterations, 2382: speed: (partUsage as BetaUsage).speed ?? usage.speed, 2383: } 2384: } 2385: export function accumulateUsage( 2386: totalUsage: Readonly<NonNullableUsage>, 2387: messageUsage: Readonly<NonNullableUsage>, 2388: ): NonNullableUsage { 2389: return { 2390: input_tokens: totalUsage.input_tokens + messageUsage.input_tokens, 2391: cache_creation_input_tokens: 2392: totalUsage.cache_creation_input_tokens + 2393: messageUsage.cache_creation_input_tokens, 2394: cache_read_input_tokens: 2395: totalUsage.cache_read_input_tokens + messageUsage.cache_read_input_tokens, 2396: output_tokens: totalUsage.output_tokens + messageUsage.output_tokens, 2397: server_tool_use: { 2398: web_search_requests: 2399: totalUsage.server_tool_use.web_search_requests + 2400: messageUsage.server_tool_use.web_search_requests, 2401: web_fetch_requests: 2402: totalUsage.server_tool_use.web_fetch_requests + 2403: messageUsage.server_tool_use.web_fetch_requests, 2404: }, 2405: service_tier: messageUsage.service_tier, 2406: cache_creation: { 2407: ephemeral_1h_input_tokens: 2408: totalUsage.cache_creation.ephemeral_1h_input_tokens + 2409: messageUsage.cache_creation.ephemeral_1h_input_tokens, 2410: ephemeral_5m_input_tokens: 2411: totalUsage.cache_creation.ephemeral_5m_input_tokens + 2412: messageUsage.cache_creation.ephemeral_5m_input_tokens, 2413: }, 2414: ...(feature('CACHED_MICROCOMPACT') 2415: ? { 2416: cache_deleted_input_tokens: 2417: ((totalUsage as unknown as { cache_deleted_input_tokens?: number }) 2418: .cache_deleted_input_tokens ?? 0) + 2419: (( 2420: messageUsage as unknown as { cache_deleted_input_tokens?: number } 2421: ).cache_deleted_input_tokens ?? 0), 2422: } 2423: : {}), 2424: inference_geo: messageUsage.inference_geo, 2425: iterations: messageUsage.iterations, 2426: speed: messageUsage.speed, 2427: } 2428: } 2429: function isToolResultBlock( 2430: block: unknown, 2431: ): block is { type: 'tool_result'; tool_use_id: string } { 2432: return ( 2433: block !== null && 2434: typeof block === 'object' && 2435: 'type' in block && 2436: (block as { type: string }).type === 'tool_result' && 2437: 'tool_use_id' in block 2438: ) 2439: } 2440: type CachedMCEditsBlock = { 2441: type: 'cache_edits' 2442: edits: { type: 'delete'; cache_reference: string }[] 2443: } 2444: type CachedMCPinnedEdits = { 2445: userMessageIndex: number 2446: block: CachedMCEditsBlock 2447: } 2448: export function addCacheBreakpoints( 2449: messages: (UserMessage | AssistantMessage)[], 2450: enablePromptCaching: boolean, 2451: querySource?: QuerySource, 2452: useCachedMC = false, 2453: newCacheEdits?: CachedMCEditsBlock | null, 2454: pinnedEdits?: CachedMCPinnedEdits[], 2455: skipCacheWrite = false, 2456: ): MessageParam[] { 2457: logEvent('tengu_api_cache_breakpoints', { 2458: totalMessageCount: messages.length, 2459: cachingEnabled: enablePromptCaching, 2460: skipCacheWrite, 2461: }) 2462: const markerIndex = skipCacheWrite ? messages.length - 2 : messages.length - 1 2463: const result = messages.map((msg, index) => { 2464: const addCache = index === markerIndex 2465: if (msg.type === 'user') { 2466: return userMessageToMessageParam( 2467: msg, 2468: addCache, 2469: enablePromptCaching, 2470: querySource, 2471: ) 2472: } 2473: return assistantMessageToMessageParam( 2474: msg, 2475: addCache, 2476: enablePromptCaching, 2477: querySource, 2478: ) 2479: }) 2480: if (!useCachedMC) { 2481: return result 2482: } 2483: const seenDeleteRefs = new Set<string>() 2484: const deduplicateEdits = (block: CachedMCEditsBlock): CachedMCEditsBlock => { 2485: const uniqueEdits = block.edits.filter(edit => { 2486: if (seenDeleteRefs.has(edit.cache_reference)) { 2487: return false 2488: } 2489: seenDeleteRefs.add(edit.cache_reference) 2490: return true 2491: }) 2492: return { ...block, edits: uniqueEdits } 2493: } 2494: for (const pinned of pinnedEdits ?? []) { 2495: const msg = result[pinned.userMessageIndex] 2496: if (msg && msg.role === 'user') { 2497: if (!Array.isArray(msg.content)) { 2498: msg.content = [{ type: 'text', text: msg.content as string }] 2499: } 2500: const dedupedBlock = deduplicateEdits(pinned.block) 2501: if (dedupedBlock.edits.length > 0) { 2502: insertBlockAfterToolResults(msg.content, dedupedBlock) 2503: } 2504: } 2505: } 2506: if (newCacheEdits && result.length > 0) { 2507: const dedupedNewEdits = deduplicateEdits(newCacheEdits) 2508: if (dedupedNewEdits.edits.length > 0) { 2509: for (let i = result.length - 1; i >= 0; i--) { 2510: const msg = result[i] 2511: if (msg && msg.role === 'user') { 2512: if (!Array.isArray(msg.content)) { 2513: msg.content = [{ type: 'text', text: msg.content as string }] 2514: } 2515: insertBlockAfterToolResults(msg.content, dedupedNewEdits) 2516: pinCacheEdits(i, newCacheEdits) 2517: logForDebugging( 2518: `Added cache_edits block with ${dedupedNewEdits.edits.length} deletion(s) to message[${i}]: ${dedupedNewEdits.edits.map(e => e.cache_reference).join(', ')}`, 2519: ) 2520: break 2521: } 2522: } 2523: } 2524: } 2525: if (enablePromptCaching) { 2526: let lastCCMsg = -1 2527: for (let i = 0; i < result.length; i++) { 2528: const msg = result[i]! 2529: if (Array.isArray(msg.content)) { 2530: for (const block of msg.content) { 2531: if (block && typeof block === 'object' && 'cache_control' in block) { 2532: lastCCMsg = i 2533: } 2534: } 2535: } 2536: } 2537: if (lastCCMsg >= 0) { 2538: for (let i = 0; i < lastCCMsg; i++) { 2539: const msg = result[i]! 2540: if (msg.role !== 'user' || !Array.isArray(msg.content)) { 2541: continue 2542: } 2543: let cloned = false 2544: for (let j = 0; j < msg.content.length; j++) { 2545: const block = msg.content[j] 2546: if (block && isToolResultBlock(block)) { 2547: if (!cloned) { 2548: msg.content = [...msg.content] 2549: cloned = true 2550: } 2551: msg.content[j] = Object.assign({}, block, { 2552: cache_reference: block.tool_use_id, 2553: }) 2554: } 2555: } 2556: } 2557: } 2558: } 2559: return result 2560: } 2561: export function buildSystemPromptBlocks( 2562: systemPrompt: SystemPrompt, 2563: enablePromptCaching: boolean, 2564: options?: { 2565: skipGlobalCacheForSystemPrompt?: boolean 2566: querySource?: QuerySource 2567: }, 2568: ): TextBlockParam[] { 2569: return splitSysPromptPrefix(systemPrompt, { 2570: skipGlobalCacheForSystemPrompt: options?.skipGlobalCacheForSystemPrompt, 2571: }).map(block => { 2572: return { 2573: type: 'text' as const, 2574: text: block.text, 2575: ...(enablePromptCaching && 2576: block.cacheScope !== null && { 2577: cache_control: getCacheControl({ 2578: scope: block.cacheScope, 2579: querySource: options?.querySource, 2580: }), 2581: }), 2582: } 2583: }) 2584: } 2585: type HaikuOptions = Omit<Options, 'model' | 'getToolPermissionContext'> 2586: export async function queryHaiku({ 2587: systemPrompt = asSystemPrompt([]), 2588: userPrompt, 2589: outputFormat, 2590: signal, 2591: options, 2592: }: { 2593: systemPrompt: SystemPrompt 2594: userPrompt: string 2595: outputFormat?: BetaJSONOutputFormat 2596: signal: AbortSignal 2597: options: HaikuOptions 2598: }): Promise<AssistantMessage> { 2599: const result = await withVCR( 2600: [ 2601: createUserMessage({ 2602: content: systemPrompt.map(text => ({ type: 'text', text })), 2603: }), 2604: createUserMessage({ 2605: content: userPrompt, 2606: }), 2607: ], 2608: async () => { 2609: const messages = [ 2610: createUserMessage({ 2611: content: userPrompt, 2612: }), 2613: ] 2614: const result = await queryModelWithoutStreaming({ 2615: messages, 2616: systemPrompt, 2617: thinkingConfig: { type: 'disabled' }, 2618: tools: [], 2619: signal, 2620: options: { 2621: ...options, 2622: model: getSmallFastModel(), 2623: enablePromptCaching: options.enablePromptCaching ?? false, 2624: outputFormat, 2625: async getToolPermissionContext() { 2626: return getEmptyToolPermissionContext() 2627: }, 2628: }, 2629: }) 2630: return [result] 2631: }, 2632: ) 2633: return result[0]! as AssistantMessage 2634: } 2635: type QueryWithModelOptions = Omit<Options, 'getToolPermissionContext'> 2636: export async function queryWithModel({ 2637: systemPrompt = asSystemPrompt([]), 2638: userPrompt, 2639: outputFormat, 2640: signal, 2641: options, 2642: }: { 2643: systemPrompt: SystemPrompt 2644: userPrompt: string 2645: outputFormat?: BetaJSONOutputFormat 2646: signal: AbortSignal 2647: options: QueryWithModelOptions 2648: }): Promise<AssistantMessage> { 2649: const result = await withVCR( 2650: [ 2651: createUserMessage({ 2652: content: systemPrompt.map(text => ({ type: 'text', text })), 2653: }), 2654: createUserMessage({ 2655: content: userPrompt, 2656: }), 2657: ], 2658: async () => { 2659: const messages = [ 2660: createUserMessage({ 2661: content: userPrompt, 2662: }), 2663: ] 2664: const result = await queryModelWithoutStreaming({ 2665: messages, 2666: systemPrompt, 2667: thinkingConfig: { type: 'disabled' }, 2668: tools: [], 2669: signal, 2670: options: { 2671: ...options, 2672: enablePromptCaching: options.enablePromptCaching ?? false, 2673: outputFormat, 2674: async getToolPermissionContext() { 2675: return getEmptyToolPermissionContext() 2676: }, 2677: }, 2678: }) 2679: return [result] 2680: }, 2681: ) 2682: return result[0]! as AssistantMessage 2683: } 2684: export const MAX_NON_STREAMING_TOKENS = 64_000 2685: export function adjustParamsForNonStreaming< 2686: T extends { 2687: max_tokens: number 2688: thinking?: BetaMessageStreamParams['thinking'] 2689: }, 2690: >(params: T, maxTokensCap: number): T { 2691: const cappedMaxTokens = Math.min(params.max_tokens, maxTokensCap) 2692: const adjustedParams = { ...params } 2693: if ( 2694: adjustedParams.thinking?.type === 'enabled' && 2695: adjustedParams.thinking.budget_tokens 2696: ) { 2697: adjustedParams.thinking = { 2698: ...adjustedParams.thinking, 2699: budget_tokens: Math.min( 2700: adjustedParams.thinking.budget_tokens, 2701: cappedMaxTokens - 1, 2702: ), 2703: } 2704: } 2705: return { 2706: ...adjustedParams, 2707: max_tokens: cappedMaxTokens, 2708: } 2709: } 2710: function isMaxTokensCapEnabled(): boolean { 2711: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_otk_slot_v1', false) 2712: } 2713: export function getMaxOutputTokensForModel(model: string): number { 2714: const maxOutputTokens = getModelMaxOutputTokens(model) 2715: const defaultTokens = isMaxTokensCapEnabled() 2716: ? Math.min(maxOutputTokens.default, CAPPED_DEFAULT_MAX_TOKENS) 2717: : maxOutputTokens.default 2718: const result = validateBoundedIntEnvVar( 2719: 'CLAUDE_CODE_MAX_OUTPUT_TOKENS', 2720: process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS, 2721: defaultTokens, 2722: maxOutputTokens.upperLimit, 2723: ) 2724: return result.effective 2725: }

File: src/services/api/client.ts

typescript 1: import Anthropic, { type ClientOptions } from '@anthropic-ai/sdk' 2: import { randomUUID } from 'crypto' 3: import type { GoogleAuth } from 'google-auth-library' 4: import { 5: checkAndRefreshOAuthTokenIfNeeded, 6: getAnthropicApiKey, 7: getApiKeyFromApiKeyHelper, 8: getClaudeAIOAuthTokens, 9: isClaudeAISubscriber, 10: refreshAndGetAwsCredentials, 11: refreshGcpCredentialsIfNeeded, 12: } from 'src/utils/auth.js' 13: import { getUserAgent } from 'src/utils/http.js' 14: import { getSmallFastModel } from 'src/utils/model/model.js' 15: import { 16: getAPIProvider, 17: isFirstPartyAnthropicBaseUrl, 18: } from 'src/utils/model/providers.js' 19: import { getProxyFetchOptions } from 'src/utils/proxy.js' 20: import { 21: getIsNonInteractiveSession, 22: getSessionId, 23: } from '../../bootstrap/state.js' 24: import { getOauthConfig } from '../../constants/oauth.js' 25: import { isDebugToStdErr, logForDebugging } from '../../utils/debug.js' 26: import { 27: getAWSRegion, 28: getVertexRegionForModel, 29: isEnvTruthy, 30: } from '../../utils/envUtils.js' 31: function createStderrLogger(): ClientOptions['logger'] { 32: return { 33: error: (msg, ...args) => 34: console.error('[Anthropic SDK ERROR]', msg, ...args), 35: warn: (msg, ...args) => console.error('[Anthropic SDK WARN]', msg, ...args), 36: info: (msg, ...args) => console.error('[Anthropic SDK INFO]', msg, ...args), 37: debug: (msg, ...args) => 38: console.error('[Anthropic SDK DEBUG]', msg, ...args), 39: } 40: } 41: export async function getAnthropicClient({ 42: apiKey, 43: maxRetries, 44: model, 45: fetchOverride, 46: source, 47: }: { 48: apiKey?: string 49: maxRetries: number 50: model?: string 51: fetchOverride?: ClientOptions['fetch'] 52: source?: string 53: }): Promise<Anthropic> { 54: const containerId = process.env.CLAUDE_CODE_CONTAINER_ID 55: const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID 56: const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP 57: const customHeaders = getCustomHeaders() 58: const defaultHeaders: { [key: string]: string } = { 59: 'x-app': 'cli', 60: 'User-Agent': getUserAgent(), 61: 'X-Claude-Code-Session-Id': getSessionId(), 62: ...customHeaders, 63: ...(containerId ? { 'x-claude-remote-container-id': containerId } : {}), 64: ...(remoteSessionId 65: ? { 'x-claude-remote-session-id': remoteSessionId } 66: : {}), 67: ...(clientApp ? { 'x-client-app': clientApp } : {}), 68: } 69: logForDebugging( 70: `[API:request] Creating client, ANTHROPIC_CUSTOM_HEADERS present: ${!!process.env.ANTHROPIC_CUSTOM_HEADERS}, has Authorization header: ${!!customHeaders['Authorization']}`, 71: ) 72: const additionalProtectionEnabled = isEnvTruthy( 73: process.env.CLAUDE_CODE_ADDITIONAL_PROTECTION, 74: ) 75: if (additionalProtectionEnabled) { 76: defaultHeaders['x-anthropic-additional-protection'] = 'true' 77: } 78: logForDebugging('[API:auth] OAuth token check starting') 79: await checkAndRefreshOAuthTokenIfNeeded() 80: logForDebugging('[API:auth] OAuth token check complete') 81: if (!isClaudeAISubscriber()) { 82: await configureApiKeyHeaders(defaultHeaders, getIsNonInteractiveSession()) 83: } 84: const resolvedFetch = buildFetch(fetchOverride, source) 85: const ARGS = { 86: defaultHeaders, 87: maxRetries, 88: timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10), 89: dangerouslyAllowBrowser: true, 90: fetchOptions: getProxyFetchOptions({ 91: forAnthropicAPI: true, 92: }) as ClientOptions['fetchOptions'], 93: ...(resolvedFetch && { 94: fetch: resolvedFetch, 95: }), 96: } 97: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) { 98: const { AnthropicBedrock } = await import('@anthropic-ai/bedrock-sdk') 99: const awsRegion = 100: model === getSmallFastModel() && 101: process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION 102: ? process.env.ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION 103: : getAWSRegion() 104: const bedrockArgs: ConstructorParameters<typeof AnthropicBedrock>[0] = { 105: ...ARGS, 106: awsRegion, 107: ...(isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH) && { 108: skipAuth: true, 109: }), 110: ...(isDebugToStdErr() && { logger: createStderrLogger() }), 111: } 112: if (process.env.AWS_BEARER_TOKEN_BEDROCK) { 113: bedrockArgs.skipAuth = true 114: bedrockArgs.defaultHeaders = { 115: ...bedrockArgs.defaultHeaders, 116: Authorization: `Bearer ${process.env.AWS_BEARER_TOKEN_BEDROCK}`, 117: } 118: } else if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { 119: const cachedCredentials = await refreshAndGetAwsCredentials() 120: if (cachedCredentials) { 121: bedrockArgs.awsAccessKey = cachedCredentials.accessKeyId 122: bedrockArgs.awsSecretKey = cachedCredentials.secretAccessKey 123: bedrockArgs.awsSessionToken = cachedCredentials.sessionToken 124: } 125: } 126: return new AnthropicBedrock(bedrockArgs) as unknown as Anthropic 127: } 128: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY)) { 129: const { AnthropicFoundry } = await import('@anthropic-ai/foundry-sdk') 130: let azureADTokenProvider: (() => Promise<string>) | undefined 131: if (!process.env.ANTHROPIC_FOUNDRY_API_KEY) { 132: if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FOUNDRY_AUTH)) { 133: azureADTokenProvider = () => Promise.resolve('') 134: } else { 135: // Use real Azure AD authentication with DefaultAzureCredential 136: const { 137: DefaultAzureCredential: AzureCredential, 138: getBearerTokenProvider, 139: } = await import('@azure/identity') 140: azureADTokenProvider = getBearerTokenProvider( 141: new AzureCredential(), 142: 'https://cognitiveservices.azure.com/.default', 143: ) 144: } 145: } 146: const foundryArgs: ConstructorParameters<typeof AnthropicFoundry>[0] = { 147: ...ARGS, 148: ...(azureADTokenProvider && { azureADTokenProvider }), 149: ...(isDebugToStdErr() && { logger: createStderrLogger() }), 150: } 151: return new AnthropicFoundry(foundryArgs) as unknown as Anthropic 152: } 153: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) { 154: if (!isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { 155: await refreshGcpCredentialsIfNeeded() 156: } 157: const [{ AnthropicVertex }, { GoogleAuth }] = await Promise.all([ 158: import('@anthropic-ai/vertex-sdk'), 159: import('google-auth-library'), 160: ]) 161: const hasProjectEnvVar = 162: process.env['GCLOUD_PROJECT'] || 163: process.env['GOOGLE_CLOUD_PROJECT'] || 164: process.env['gcloud_project'] || 165: process.env['google_cloud_project'] 166: const hasKeyFile = 167: process.env['GOOGLE_APPLICATION_CREDENTIALS'] || 168: process.env['google_application_credentials'] 169: const googleAuth = isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH) 170: ? ({ 171: getClient: () => ({ 172: getRequestHeaders: () => ({}), 173: }), 174: } as unknown as GoogleAuth) 175: : new GoogleAuth({ 176: scopes: ['https://www.googleapis.com/auth/cloud-platform'], 177: ...(hasProjectEnvVar || hasKeyFile 178: ? {} 179: : { 180: projectId: process.env.ANTHROPIC_VERTEX_PROJECT_ID, 181: }), 182: }) 183: const vertexArgs: ConstructorParameters<typeof AnthropicVertex>[0] = { 184: ...ARGS, 185: region: getVertexRegionForModel(model), 186: googleAuth, 187: ...(isDebugToStdErr() && { logger: createStderrLogger() }), 188: } 189: return new AnthropicVertex(vertexArgs) as unknown as Anthropic 190: } 191: const clientConfig: ConstructorParameters<typeof Anthropic>[0] = { 192: apiKey: isClaudeAISubscriber() ? null : apiKey || getAnthropicApiKey(), 193: authToken: isClaudeAISubscriber() 194: ? getClaudeAIOAuthTokens()?.accessToken 195: : undefined, 196: ...(process.env.USER_TYPE === 'ant' && 197: isEnvTruthy(process.env.USE_STAGING_OAUTH) 198: ? { baseURL: getOauthConfig().BASE_API_URL } 199: : {}), 200: ...ARGS, 201: ...(isDebugToStdErr() && { logger: createStderrLogger() }), 202: } 203: return new Anthropic(clientConfig) 204: } 205: async function configureApiKeyHeaders( 206: headers: Record<string, string>, 207: isNonInteractiveSession: boolean, 208: ): Promise<void> { 209: const token = 210: process.env.ANTHROPIC_AUTH_TOKEN || 211: (await getApiKeyFromApiKeyHelper(isNonInteractiveSession)) 212: if (token) { 213: headers['Authorization'] = `Bearer ${token}` 214: } 215: } 216: function getCustomHeaders(): Record<string, string> { 217: const customHeaders: Record<string, string> = {} 218: const customHeadersEnv = process.env.ANTHROPIC_CUSTOM_HEADERS 219: if (!customHeadersEnv) return customHeaders 220: const headerStrings = customHeadersEnv.split(/\n|\r\n/) 221: for (const headerString of headerStrings) { 222: if (!headerString.trim()) continue 223: const colonIdx = headerString.indexOf(':') 224: if (colonIdx === -1) continue 225: const name = headerString.slice(0, colonIdx).trim() 226: const value = headerString.slice(colonIdx + 1).trim() 227: if (name) { 228: customHeaders[name] = value 229: } 230: } 231: return customHeaders 232: } 233: export const CLIENT_REQUEST_ID_HEADER = 'x-client-request-id' 234: function buildFetch( 235: fetchOverride: ClientOptions['fetch'], 236: source: string | undefined, 237: ): ClientOptions['fetch'] { 238: const inner = fetchOverride ?? globalThis.fetch 239: const injectClientRequestId = 240: getAPIProvider() === 'firstParty' && isFirstPartyAnthropicBaseUrl() 241: return (input, init) => { 242: const headers = new Headers(init?.headers) 243: if (injectClientRequestId && !headers.has(CLIENT_REQUEST_ID_HEADER)) { 244: headers.set(CLIENT_REQUEST_ID_HEADER, randomUUID()) 245: } 246: try { 247: const url = input instanceof Request ? input.url : String(input) 248: const id = headers.get(CLIENT_REQUEST_ID_HEADER) 249: logForDebugging( 250: `[API REQUEST] ${new URL(url).pathname}${id ? ` ${CLIENT_REQUEST_ID_HEADER}=${id}` : ''} source=${source ?? 'unknown'}`, 251: ) 252: } catch { 253: } 254: return inner(input, { ...init, headers }) 255: } 256: }

File: src/services/api/dumpPrompts.ts

typescript 1: import type { ClientOptions } from '@anthropic-ai/sdk' 2: import { createHash } from 'crypto' 3: import { promises as fs } from 'fs' 4: import { dirname, join } from 'path' 5: import { getSessionId } from 'src/bootstrap/state.js' 6: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 7: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 8: function hashString(str: string): string { 9: return createHash('sha256').update(str).digest('hex') 10: } 11: const MAX_CACHED_REQUESTS = 5 12: const cachedApiRequests: Array<{ timestamp: string; request: unknown }> = [] 13: type DumpState = { 14: initialized: boolean 15: messageCountSeen: number 16: lastInitDataHash: string 17: lastInitFingerprint: string 18: } 19: const dumpState = new Map<string, DumpState>() 20: export function getLastApiRequests(): Array<{ 21: timestamp: string 22: request: unknown 23: }> { 24: return [...cachedApiRequests] 25: } 26: export function clearApiRequestCache(): void { 27: cachedApiRequests.length = 0 28: } 29: export function clearDumpState(agentIdOrSessionId: string): void { 30: dumpState.delete(agentIdOrSessionId) 31: } 32: export function clearAllDumpState(): void { 33: dumpState.clear() 34: } 35: export function addApiRequestToCache(requestData: unknown): void { 36: if (process.env.USER_TYPE !== 'ant') return 37: cachedApiRequests.push({ 38: timestamp: new Date().toISOString(), 39: request: requestData, 40: }) 41: if (cachedApiRequests.length > MAX_CACHED_REQUESTS) { 42: cachedApiRequests.shift() 43: } 44: } 45: export function getDumpPromptsPath(agentIdOrSessionId?: string): string { 46: return join( 47: getClaudeConfigHomeDir(), 48: 'dump-prompts', 49: `${agentIdOrSessionId ?? getSessionId()}.jsonl`, 50: ) 51: } 52: function appendToFile(filePath: string, entries: string[]): void { 53: if (entries.length === 0) return 54: fs.mkdir(dirname(filePath), { recursive: true }) 55: .then(() => fs.appendFile(filePath, entries.join('\n') + '\n')) 56: .catch(() => {}) 57: } 58: function initFingerprint(req: Record<string, unknown>): string { 59: const tools = req.tools as Array<{ name?: string }> | undefined 60: const system = req.system as unknown[] | string | undefined 61: const sysLen = 62: typeof system === 'string' 63: ? system.length 64: : Array.isArray(system) 65: ? system.reduce( 66: (n: number, b) => n + ((b as { text?: string }).text?.length ?? 0), 67: 0, 68: ) 69: : 0 70: const toolNames = tools?.map(t => t.name ?? '').join(',') ?? '' 71: return `${req.model}|${toolNames}|${sysLen}` 72: } 73: function dumpRequest( 74: body: string, 75: ts: string, 76: state: DumpState, 77: filePath: string, 78: ): void { 79: try { 80: const req = jsonParse(body) as Record<string, unknown> 81: addApiRequestToCache(req) 82: if (process.env.USER_TYPE !== 'ant') return 83: const entries: string[] = [] 84: const messages = (req.messages ?? []) as Array<{ role?: string }> 85: const fingerprint = initFingerprint(req) 86: if (!state.initialized || fingerprint !== state.lastInitFingerprint) { 87: const { messages: _, ...initData } = req 88: const initDataStr = jsonStringify(initData) 89: const initDataHash = hashString(initDataStr) 90: state.lastInitFingerprint = fingerprint 91: if (!state.initialized) { 92: state.initialized = true 93: state.lastInitDataHash = initDataHash 94: entries.push( 95: `{"type":"init","timestamp":"${ts}","data":${initDataStr}}`, 96: ) 97: } else if (initDataHash !== state.lastInitDataHash) { 98: state.lastInitDataHash = initDataHash 99: entries.push( 100: `{"type":"system_update","timestamp":"${ts}","data":${initDataStr}}`, 101: ) 102: } 103: } 104: for (const msg of messages.slice(state.messageCountSeen)) { 105: if (msg.role === 'user') { 106: entries.push( 107: jsonStringify({ type: 'message', timestamp: ts, data: msg }), 108: ) 109: } 110: } 111: state.messageCountSeen = messages.length 112: appendToFile(filePath, entries) 113: } catch { 114: } 115: } 116: export function createDumpPromptsFetch( 117: agentIdOrSessionId: string, 118: ): ClientOptions['fetch'] { 119: const filePath = getDumpPromptsPath(agentIdOrSessionId) 120: return async (input: RequestInfo | URL, init?: RequestInit) => { 121: const state = dumpState.get(agentIdOrSessionId) ?? { 122: initialized: false, 123: messageCountSeen: 0, 124: lastInitDataHash: '', 125: lastInitFingerprint: '', 126: } 127: dumpState.set(agentIdOrSessionId, state) 128: let timestamp: string | undefined 129: if (init?.method === 'POST' && init.body) { 130: timestamp = new Date().toISOString() 131: setImmediate(dumpRequest, init.body as string, timestamp, state, filePath) 132: } 133: const response = await globalThis.fetch(input, init) 134: if (timestamp && response.ok && process.env.USER_TYPE === 'ant') { 135: const cloned = response.clone() 136: void (async () => { 137: try { 138: const isStreaming = cloned.headers 139: .get('content-type') 140: ?.includes('text/event-stream') 141: let data: unknown 142: if (isStreaming && cloned.body) { 143: const reader = cloned.body.getReader() 144: const decoder = new TextDecoder() 145: let buffer = '' 146: try { 147: while (true) { 148: const { done, value } = await reader.read() 149: if (done) break 150: buffer += decoder.decode(value, { stream: true }) 151: } 152: } finally { 153: reader.releaseLock() 154: } 155: const chunks: unknown[] = [] 156: for (const event of buffer.split('\n\n')) { 157: for (const line of event.split('\n')) { 158: if (line.startsWith('data: ') && line !== 'data: [DONE]') { 159: try { 160: chunks.push(jsonParse(line.slice(6))) 161: } catch { 162: } 163: } 164: } 165: } 166: data = { stream: true, chunks } 167: } else { 168: data = await cloned.json() 169: } 170: await fs.appendFile( 171: filePath, 172: jsonStringify({ type: 'response', timestamp, data }) + '\n', 173: ) 174: } catch { 175: } 176: })() 177: } 178: return response 179: } 180: }

File: src/services/api/emptyUsage.ts

typescript 1: import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js' 2: export const EMPTY_USAGE: Readonly<NonNullableUsage> = { 3: input_tokens: 0, 4: cache_creation_input_tokens: 0, 5: cache_read_input_tokens: 0, 6: output_tokens: 0, 7: server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 }, 8: service_tier: 'standard', 9: cache_creation: { 10: ephemeral_1h_input_tokens: 0, 11: ephemeral_5m_input_tokens: 0, 12: }, 13: inference_geo: '', 14: iterations: [], 15: speed: 'standard', 16: }

File: src/services/api/errors.ts

typescript 1: import { 2: APIConnectionError, 3: APIConnectionTimeoutError, 4: APIError, 5: } from '@anthropic-ai/sdk' 6: import type { 7: BetaMessage, 8: BetaStopReason, 9: } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 10: import { AFK_MODE_BETA_HEADER } from 'src/constants/betas.js' 11: import type { SDKAssistantMessageError } from 'src/entrypoints/agentSdkTypes.js' 12: import type { 13: AssistantMessage, 14: Message, 15: UserMessage, 16: } from 'src/types/message.js' 17: import { 18: getAnthropicApiKeyWithSource, 19: getClaudeAIOAuthTokens, 20: getOauthAccountInfo, 21: isClaudeAISubscriber, 22: } from 'src/utils/auth.js' 23: import { 24: createAssistantAPIErrorMessage, 25: NO_RESPONSE_REQUESTED, 26: } from 'src/utils/messages.js' 27: import { 28: getDefaultMainLoopModelSetting, 29: isNonCustomOpusModel, 30: } from 'src/utils/model/model.js' 31: import { getModelStrings } from 'src/utils/model/modelStrings.js' 32: import { getAPIProvider } from 'src/utils/model/providers.js' 33: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 34: import { 35: API_PDF_MAX_PAGES, 36: PDF_TARGET_RAW_SIZE, 37: } from '../../constants/apiLimits.js' 38: import { isEnvTruthy } from '../../utils/envUtils.js' 39: import { formatFileSize } from '../../utils/format.js' 40: import { ImageResizeError } from '../../utils/imageResizer.js' 41: import { ImageSizeError } from '../../utils/imageValidation.js' 42: import { 43: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 44: logEvent, 45: } from '../analytics/index.js' 46: import { 47: type ClaudeAILimits, 48: getRateLimitErrorMessage, 49: type OverageDisabledReason, 50: } from '../claudeAiLimits.js' 51: import { shouldProcessRateLimits } from '../rateLimitMocking.js' 52: import { extractConnectionErrorDetails, formatAPIError } from './errorUtils.js' 53: export const API_ERROR_MESSAGE_PREFIX = 'API Error' 54: export function startsWithApiErrorPrefix(text: string): boolean { 55: return ( 56: text.startsWith(API_ERROR_MESSAGE_PREFIX) || 57: text.startsWith(`Please run /login · ${API_ERROR_MESSAGE_PREFIX}`) 58: ) 59: } 60: export const PROMPT_TOO_LONG_ERROR_MESSAGE = 'Prompt is too long' 61: export function isPromptTooLongMessage(msg: AssistantMessage): boolean { 62: if (!msg.isApiErrorMessage) { 63: return false 64: } 65: const content = msg.message.content 66: if (!Array.isArray(content)) { 67: return false 68: } 69: return content.some( 70: block => 71: block.type === 'text' && 72: block.text.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE), 73: ) 74: } 75: export function parsePromptTooLongTokenCounts(rawMessage: string): { 76: actualTokens: number | undefined 77: limitTokens: number | undefined 78: } { 79: const match = rawMessage.match( 80: /prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)/i, 81: ) 82: return { 83: actualTokens: match ? parseInt(match[1]!, 10) : undefined, 84: limitTokens: match ? parseInt(match[2]!, 10) : undefined, 85: } 86: } 87: export function getPromptTooLongTokenGap( 88: msg: AssistantMessage, 89: ): number | undefined { 90: if (!isPromptTooLongMessage(msg) || !msg.errorDetails) { 91: return undefined 92: } 93: const { actualTokens, limitTokens } = parsePromptTooLongTokenCounts( 94: msg.errorDetails, 95: ) 96: if (actualTokens === undefined || limitTokens === undefined) { 97: return undefined 98: } 99: const gap = actualTokens - limitTokens 100: return gap > 0 ? gap : undefined 101: } 102: export function isMediaSizeError(raw: string): boolean { 103: return ( 104: (raw.includes('image exceeds') && raw.includes('maximum')) || 105: (raw.includes('image dimensions exceed') && raw.includes('many-image')) || 106: /maximum of \d+ PDF pages/.test(raw) 107: ) 108: } 109: export function isMediaSizeErrorMessage(msg: AssistantMessage): boolean { 110: return ( 111: msg.isApiErrorMessage === true && 112: msg.errorDetails !== undefined && 113: isMediaSizeError(msg.errorDetails) 114: ) 115: } 116: export const CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE = 'Credit balance is too low' 117: export const INVALID_API_KEY_ERROR_MESSAGE = 'Not logged in · Please run /login' 118: export const INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL = 119: 'Invalid API key · Fix external API key' 120: export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH = 121: 'Your ANTHROPIC_API_KEY belongs to a disabled organization · Unset the environment variable to use your subscription instead' 122: export const ORG_DISABLED_ERROR_MESSAGE_ENV_KEY = 123: 'Your ANTHROPIC_API_KEY belongs to a disabled organization · Update or unset the environment variable' 124: export const TOKEN_REVOKED_ERROR_MESSAGE = 125: 'OAuth token revoked · Please run /login' 126: export const CCR_AUTH_ERROR_MESSAGE = 127: 'Authentication error · This may be a temporary network issue, please try again' 128: export const REPEATED_529_ERROR_MESSAGE = 'Repeated 529 Overloaded errors' 129: export const CUSTOM_OFF_SWITCH_MESSAGE = 130: 'Opus is experiencing high load, please use /model to switch to Sonnet' 131: export const API_TIMEOUT_ERROR_MESSAGE = 'Request timed out' 132: export function getPdfTooLargeErrorMessage(): string { 133: const limits = `max ${API_PDF_MAX_PAGES} pages, ${formatFileSize(PDF_TARGET_RAW_SIZE)}` 134: return getIsNonInteractiveSession() 135: ? `PDF too large (${limits}). Try reading the file a different way (e.g., extract text with pdftotext).` 136: : `PDF too large (${limits}). Double press esc to go back and try again, or use pdftotext to convert to text first.` 137: } 138: export function getPdfPasswordProtectedErrorMessage(): string { 139: return getIsNonInteractiveSession() 140: ? 'PDF is password protected. Try using a CLI tool to extract or convert the PDF.' 141: : 'PDF is password protected. Please double press esc to edit your message and try again.' 142: } 143: export function getPdfInvalidErrorMessage(): string { 144: return getIsNonInteractiveSession() 145: ? 'The PDF file was not valid. Try converting it to text first (e.g., pdftotext).' 146: : 'The PDF file was not valid. Double press esc to go back and try again with a different file.' 147: } 148: export function getImageTooLargeErrorMessage(): string { 149: return getIsNonInteractiveSession() 150: ? 'Image was too large. Try resizing the image or using a different approach.' 151: : 'Image was too large. Double press esc to go back and try again with a smaller image.' 152: } 153: export function getRequestTooLargeErrorMessage(): string { 154: const limits = `max ${formatFileSize(PDF_TARGET_RAW_SIZE)}` 155: return getIsNonInteractiveSession() 156: ? `Request too large (${limits}). Try with a smaller file.` 157: : `Request too large (${limits}). Double press esc to go back and try with a smaller file.` 158: } 159: export const OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE = 160: 'Your account does not have access to Claude Code. Please run /login.' 161: export function getTokenRevokedErrorMessage(): string { 162: return getIsNonInteractiveSession() 163: ? 'Your account does not have access to Claude. Please login again or contact your administrator.' 164: : TOKEN_REVOKED_ERROR_MESSAGE 165: } 166: export function getOauthOrgNotAllowedErrorMessage(): string { 167: return getIsNonInteractiveSession() 168: ? 'Your organization does not have access to Claude. Please login again or contact your administrator.' 169: : OAUTH_ORG_NOT_ALLOWED_ERROR_MESSAGE 170: } 171: function isCCRMode(): boolean { 172: return isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) 173: } 174: function logToolUseToolResultMismatch( 175: toolUseId: string, 176: messages: Message[], 177: messagesForAPI: (UserMessage | AssistantMessage)[], 178: ): void { 179: try { 180: let normalizedIndex = -1 181: for (let i = 0; i < messagesForAPI.length; i++) { 182: const msg = messagesForAPI[i] 183: if (!msg) continue 184: const content = msg.message.content 185: if (Array.isArray(content)) { 186: for (const block of content) { 187: if ( 188: block.type === 'tool_use' && 189: 'id' in block && 190: block.id === toolUseId 191: ) { 192: normalizedIndex = i 193: break 194: } 195: } 196: } 197: if (normalizedIndex !== -1) break 198: } 199: let originalIndex = -1 200: for (let i = 0; i < messages.length; i++) { 201: const msg = messages[i] 202: if (!msg) continue 203: if (msg.type === 'assistant' && 'message' in msg) { 204: const content = msg.message.content 205: if (Array.isArray(content)) { 206: for (const block of content) { 207: if ( 208: block.type === 'tool_use' && 209: 'id' in block && 210: block.id === toolUseId 211: ) { 212: originalIndex = i 213: break 214: } 215: } 216: } 217: } 218: if (originalIndex !== -1) break 219: } 220: const normalizedSeq: string[] = [] 221: for (let i = normalizedIndex + 1; i < messagesForAPI.length; i++) { 222: const msg = messagesForAPI[i] 223: if (!msg) continue 224: const content = msg.message.content 225: if (Array.isArray(content)) { 226: for (const block of content) { 227: const role = msg.message.role 228: if (block.type === 'tool_use' && 'id' in block) { 229: normalizedSeq.push(`${role}:tool_use:${block.id}`) 230: } else if (block.type === 'tool_result' && 'tool_use_id' in block) { 231: normalizedSeq.push(`${role}:tool_result:${block.tool_use_id}`) 232: } else if (block.type === 'text') { 233: normalizedSeq.push(`${role}:text`) 234: } else if (block.type === 'thinking') { 235: normalizedSeq.push(`${role}:thinking`) 236: } else if (block.type === 'image') { 237: normalizedSeq.push(`${role}:image`) 238: } else { 239: normalizedSeq.push(`${role}:${block.type}`) 240: } 241: } 242: } else if (typeof content === 'string') { 243: normalizedSeq.push(`${msg.message.role}:string_content`) 244: } 245: } 246: const preNormalizedSeq: string[] = [] 247: for (let i = originalIndex + 1; i < messages.length; i++) { 248: const msg = messages[i] 249: if (!msg) continue 250: switch (msg.type) { 251: case 'user': 252: case 'assistant': { 253: if ('message' in msg) { 254: const content = msg.message.content 255: if (Array.isArray(content)) { 256: for (const block of content) { 257: const role = msg.message.role 258: if (block.type === 'tool_use' && 'id' in block) { 259: preNormalizedSeq.push(`${role}:tool_use:${block.id}`) 260: } else if ( 261: block.type === 'tool_result' && 262: 'tool_use_id' in block 263: ) { 264: preNormalizedSeq.push( 265: `${role}:tool_result:${block.tool_use_id}`, 266: ) 267: } else if (block.type === 'text') { 268: preNormalizedSeq.push(`${role}:text`) 269: } else if (block.type === 'thinking') { 270: preNormalizedSeq.push(`${role}:thinking`) 271: } else if (block.type === 'image') { 272: preNormalizedSeq.push(`${role}:image`) 273: } else { 274: preNormalizedSeq.push(`${role}:${block.type}`) 275: } 276: } 277: } else if (typeof content === 'string') { 278: preNormalizedSeq.push(`${msg.message.role}:string_content`) 279: } 280: } 281: break 282: } 283: case 'attachment': 284: if ('attachment' in msg) { 285: preNormalizedSeq.push(`attachment:${msg.attachment.type}`) 286: } 287: break 288: case 'system': 289: if ('subtype' in msg) { 290: preNormalizedSeq.push(`system:${msg.subtype}`) 291: } 292: break 293: case 'progress': 294: if ( 295: 'progress' in msg && 296: msg.progress && 297: typeof msg.progress === 'object' && 298: 'type' in msg.progress 299: ) { 300: preNormalizedSeq.push(`progress:${msg.progress.type ?? 'unknown'}`) 301: } else { 302: preNormalizedSeq.push('progress:unknown') 303: } 304: break 305: } 306: } 307: logEvent('tengu_tool_use_tool_result_mismatch_error', { 308: toolUseId: 309: toolUseId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 310: normalizedSequence: normalizedSeq.join( 311: ', ', 312: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 313: preNormalizedSequence: preNormalizedSeq.join( 314: ', ', 315: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 316: normalizedMessageCount: messagesForAPI.length, 317: originalMessageCount: messages.length, 318: normalizedToolUseIndex: normalizedIndex, 319: originalToolUseIndex: originalIndex, 320: }) 321: } catch (_) { 322: } 323: } 324: export function isValidAPIMessage(value: unknown): value is BetaMessage { 325: return ( 326: typeof value === 'object' && 327: value !== null && 328: 'content' in value && 329: 'model' in value && 330: 'usage' in value && 331: Array.isArray((value as BetaMessage).content) && 332: typeof (value as BetaMessage).model === 'string' && 333: typeof (value as BetaMessage).usage === 'object' 334: ) 335: } 336: type AmazonError = { 337: Output?: { 338: __type?: string 339: } 340: Version?: string 341: } 342: export function extractUnknownErrorFormat(value: unknown): string | undefined { 343: if (!value || typeof value !== 'object') { 344: return undefined 345: } 346: if ((value as AmazonError).Output?.__type) { 347: return (value as AmazonError).Output!.__type 348: } 349: return undefined 350: } 351: export function getAssistantMessageFromError( 352: error: unknown, 353: model: string, 354: options?: { 355: messages?: Message[] 356: messagesForAPI?: (UserMessage | AssistantMessage)[] 357: }, 358: ): AssistantMessage { 359: if ( 360: error instanceof APIConnectionTimeoutError || 361: (error instanceof APIConnectionError && 362: error.message.toLowerCase().includes('timeout')) 363: ) { 364: return createAssistantAPIErrorMessage({ 365: content: API_TIMEOUT_ERROR_MESSAGE, 366: error: 'unknown', 367: }) 368: } 369: if (error instanceof ImageSizeError || error instanceof ImageResizeError) { 370: return createAssistantAPIErrorMessage({ 371: content: getImageTooLargeErrorMessage(), 372: }) 373: } 374: if ( 375: error instanceof Error && 376: error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE) 377: ) { 378: return createAssistantAPIErrorMessage({ 379: content: CUSTOM_OFF_SWITCH_MESSAGE, 380: error: 'rate_limit', 381: }) 382: } 383: if ( 384: error instanceof APIError && 385: error.status === 429 && 386: shouldProcessRateLimits(isClaudeAISubscriber()) 387: ) { 388: const rateLimitType = error.headers?.get?.( 389: 'anthropic-ratelimit-unified-representative-claim', 390: ) as 'five_hour' | 'seven_day' | 'seven_day_opus' | null 391: const overageStatus = error.headers?.get?.( 392: 'anthropic-ratelimit-unified-overage-status', 393: ) as 'allowed' | 'allowed_warning' | 'rejected' | null 394: if (rateLimitType || overageStatus) { 395: const limits: ClaudeAILimits = { 396: status: 'rejected', 397: unifiedRateLimitFallbackAvailable: false, 398: isUsingOverage: false, 399: } 400: const resetHeader = error.headers?.get?.( 401: 'anthropic-ratelimit-unified-reset', 402: ) 403: if (resetHeader) { 404: limits.resetsAt = Number(resetHeader) 405: } 406: if (rateLimitType) { 407: limits.rateLimitType = rateLimitType 408: } 409: if (overageStatus) { 410: limits.overageStatus = overageStatus 411: } 412: const overageResetHeader = error.headers?.get?.( 413: 'anthropic-ratelimit-unified-overage-reset', 414: ) 415: if (overageResetHeader) { 416: limits.overageResetsAt = Number(overageResetHeader) 417: } 418: const overageDisabledReason = error.headers?.get?.( 419: 'anthropic-ratelimit-unified-overage-disabled-reason', 420: ) as OverageDisabledReason | null 421: if (overageDisabledReason) { 422: limits.overageDisabledReason = overageDisabledReason 423: } 424: const specificErrorMessage = getRateLimitErrorMessage(limits, model) 425: if (specificErrorMessage) { 426: return createAssistantAPIErrorMessage({ 427: content: specificErrorMessage, 428: error: 'rate_limit', 429: }) 430: } 431: return createAssistantAPIErrorMessage({ 432: content: NO_RESPONSE_REQUESTED, 433: error: 'rate_limit', 434: }) 435: } 436: if (error.message.includes('Extra usage is required for long context')) { 437: const hint = getIsNonInteractiveSession() 438: ? 'enable extra usage at claude.ai/settings/usage, or use --model to switch to standard context' 439: : 'run /extra-usage to enable, or /model to switch to standard context' 440: return createAssistantAPIErrorMessage({ 441: content: `${API_ERROR_MESSAGE_PREFIX}: Extra usage is required for 1M context · ${hint}`, 442: error: 'rate_limit', 443: }) 444: } 445: const stripped = error.message.replace(/^429\s+/, '') 446: const innerMessage = stripped.match(/"message"\s*:\s*"([^"]*)"/)?.[1] 447: const detail = innerMessage || stripped 448: return createAssistantAPIErrorMessage({ 449: content: `${API_ERROR_MESSAGE_PREFIX}: Request rejected (429) · ${detail || 'this may be a temporary capacity issue — check status.anthropic.com'}`, 450: error: 'rate_limit', 451: }) 452: } 453: // Handle prompt too long errors (Vertex returns 413, direct API returns 400) 454: // Use case-insensitive check since Vertex returns "Prompt is too long" (capitalized) 455: if ( 456: error instanceof Error && 457: error.message.toLowerCase().includes('prompt is too long') 458: ) { 459: // Content stays generic (UI matches on exact string). The raw error with 460: // token counts goes into errorDetails — reactive compact's retry loop 461: // parses the gap from there via getPromptTooLongTokenGap. 462: return createAssistantAPIErrorMessage({ 463: content: PROMPT_TOO_LONG_ERROR_MESSAGE, 464: error: 'invalid_request', 465: errorDetails: error.message, 466: }) 467: } 468: // Check for PDF page limit errors 469: if ( 470: error instanceof Error && 471: /maximum of \d+ PDF pages/.test(error.message) 472: ) { 473: return createAssistantAPIErrorMessage({ 474: content: getPdfTooLargeErrorMessage(), 475: error: 'invalid_request', 476: errorDetails: error.message, 477: }) 478: } 479: // Check for password-protected PDF errors 480: if ( 481: error instanceof Error && 482: error.message.includes('The PDF specified is password protected') 483: ) { 484: return createAssistantAPIErrorMessage({ 485: content: getPdfPasswordProtectedErrorMessage(), 486: error: 'invalid_request', 487: }) 488: } 489: // Check for invalid PDF errors (e.g., HTML file renamed to .pdf) 490: // Without this handler, invalid PDF document blocks persist in conversation 491: // context and cause every subsequent API call to fail with 400. 492: if ( 493: error instanceof Error && 494: error.message.includes('The PDF specified was not valid') 495: ) { 496: return createAssistantAPIErrorMessage({ 497: content: getPdfInvalidErrorMessage(), 498: error: 'invalid_request', 499: }) 500: } 501: // Check for image size errors (e.g., "image exceeds 5 MB maximum: 5316852 bytes > 5242880 bytes") 502: if ( 503: error instanceof APIError && 504: error.status === 400 && 505: error.message.includes('image exceeds') && 506: error.message.includes('maximum') 507: ) { 508: return createAssistantAPIErrorMessage({ 509: content: getImageTooLargeErrorMessage(), 510: errorDetails: error.message, 511: }) 512: } 513: // Check for many-image dimension errors (API enforces stricter 2000px limit for many-image requests) 514: if ( 515: error instanceof APIError && 516: error.status === 400 && 517: error.message.includes('image dimensions exceed') && 518: error.message.includes('many-image') 519: ) { 520: return createAssistantAPIErrorMessage({ 521: content: getIsNonInteractiveSession() 522: ? 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Start a new session with fewer images.' 523: : 'An image in the conversation exceeds the dimension limit for many-image requests (2000px). Run /compact to remove old images from context, or start a new session.', 524: error: 'invalid_request', 525: errorDetails: error.message, 526: }) 527: } 528: // Server rejected the afk-mode beta header (plan does not include auto 529: // mode). AFK_MODE_BETA_HEADER is '' in non-TRANSCRIPT_CLASSIFIER builds, 530: // so the truthy guard keeps this inert there. 531: if ( 532: AFK_MODE_BETA_HEADER && 533: error instanceof APIError && 534: error.status === 400 && 535: error.message.includes(AFK_MODE_BETA_HEADER) && 536: error.message.includes('anthropic-beta') 537: ) { 538: return createAssistantAPIErrorMessage({ 539: content: 'Auto mode is unavailable for your plan', 540: error: 'invalid_request', 541: }) 542: } 543: // Check for request too large errors (413 status) 544: // This typically happens when a large PDF + conversation context exceeds the 32MB API limit 545: if (error instanceof APIError && error.status === 413) { 546: return createAssistantAPIErrorMessage({ 547: content: getRequestTooLargeErrorMessage(), 548: error: 'invalid_request', 549: }) 550: } 551: // Check for tool_use/tool_result concurrency error 552: if ( 553: error instanceof APIError && 554: error.status === 400 && 555: error.message.includes( 556: '`tool_use` ids were found without `tool_result` blocks immediately after', 557: ) 558: ) { 559: if (options?.messages && options?.messagesForAPI) { 560: const toolUseIdMatch = error.message.match(/toolu_[a-zA-Z0-9]+/) 561: const toolUseId = toolUseIdMatch ? toolUseIdMatch[0] : null 562: if (toolUseId) { 563: logToolUseToolResultMismatch( 564: toolUseId, 565: options.messages, 566: options.messagesForAPI, 567: ) 568: } 569: } 570: if (process.env.USER_TYPE === 'ant') { 571: const baseMessage = `API Error: 400 ${error.message}\n\nRun /share and post the JSON file to ${MACRO.FEEDBACK_CHANNEL}.` 572: const rewindInstruction = getIsNonInteractiveSession() 573: ? '' 574: : ' Then, use /rewind to recover the conversation.' 575: return createAssistantAPIErrorMessage({ 576: content: baseMessage + rewindInstruction, 577: error: 'invalid_request', 578: }) 579: } else { 580: const baseMessage = 'API Error: 400 due to tool use concurrency issues.' 581: const rewindInstruction = getIsNonInteractiveSession() 582: ? '' 583: : ' Run /rewind to recover the conversation.' 584: return createAssistantAPIErrorMessage({ 585: content: baseMessage + rewindInstruction, 586: error: 'invalid_request', 587: }) 588: } 589: } 590: if ( 591: error instanceof APIError && 592: error.status === 400 && 593: error.message.includes('unexpected `tool_use_id` found in `tool_result`') 594: ) { 595: logEvent('tengu_unexpected_tool_result', {}) 596: } 597: if ( 598: error instanceof APIError && 599: error.status === 400 && 600: error.message.includes('`tool_use` ids must be unique') 601: ) { 602: logEvent('tengu_duplicate_tool_use_id', {}) 603: const rewindInstruction = getIsNonInteractiveSession() 604: ? '' 605: : ' Run /rewind to recover the conversation.' 606: return createAssistantAPIErrorMessage({ 607: content: `API Error: 400 duplicate tool_use ID in conversation history.${rewindInstruction}`, 608: error: 'invalid_request', 609: errorDetails: error.message, 610: }) 611: } 612: if ( 613: isClaudeAISubscriber() && 614: error instanceof APIError && 615: error.status === 400 && 616: error.message.toLowerCase().includes('invalid model name') && 617: (isNonCustomOpusModel(model) || model === 'opus') 618: ) { 619: return createAssistantAPIErrorMessage({ 620: content: 621: 'Claude Opus is not available with the Claude Pro plan. If you have updated your subscription plan recently, run /logout and /login for the plan to take effect.', 622: error: 'invalid_request', 623: }) 624: } 625: if ( 626: process.env.USER_TYPE === 'ant' && 627: !process.env.ANTHROPIC_MODEL && 628: error instanceof Error && 629: error.message.toLowerCase().includes('invalid model name') 630: ) { 631: const orgId = getOauthAccountInfo()?.organizationUuid 632: const baseMsg = `[ANT-ONLY] Your org isn't gated into the \`${model}\` model. Either run \`claude\` with \`ANTHROPIC_MODEL=${getDefaultMainLoopModelSetting()}\`` 633: const msg = orgId 634: ? `${baseMsg} or share your orgId (${orgId}) in ${MACRO.FEEDBACK_CHANNEL} for help getting access.` 635: : `${baseMsg} or reach out in ${MACRO.FEEDBACK_CHANNEL} for help getting access.` 636: return createAssistantAPIErrorMessage({ 637: content: msg, 638: error: 'invalid_request', 639: }) 640: } 641: if ( 642: error instanceof Error && 643: error.message.includes('Your credit balance is too low') 644: ) { 645: return createAssistantAPIErrorMessage({ 646: content: CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, 647: error: 'billing_error', 648: }) 649: } 650: if ( 651: error instanceof APIError && 652: error.status === 400 && 653: error.message.toLowerCase().includes('organization has been disabled') 654: ) { 655: const { source } = getAnthropicApiKeyWithSource() 656: if ( 657: source === 'ANTHROPIC_API_KEY' && 658: process.env.ANTHROPIC_API_KEY && 659: !isClaudeAISubscriber() 660: ) { 661: const hasStoredOAuth = getClaudeAIOAuthTokens()?.accessToken != null 662: return createAssistantAPIErrorMessage({ 663: error: 'invalid_request', 664: content: hasStoredOAuth 665: ? ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH 666: : ORG_DISABLED_ERROR_MESSAGE_ENV_KEY, 667: }) 668: } 669: } 670: if ( 671: error instanceof Error && 672: error.message.toLowerCase().includes('x-api-key') 673: ) { 674: if (isCCRMode()) { 675: return createAssistantAPIErrorMessage({ 676: error: 'authentication_failed', 677: content: CCR_AUTH_ERROR_MESSAGE, 678: }) 679: } 680: const { source } = getAnthropicApiKeyWithSource() 681: const isExternalSource = 682: source === 'ANTHROPIC_API_KEY' || source === 'apiKeyHelper' 683: return createAssistantAPIErrorMessage({ 684: error: 'authentication_failed', 685: content: isExternalSource 686: ? INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL 687: : INVALID_API_KEY_ERROR_MESSAGE, 688: }) 689: } 690: if ( 691: error instanceof APIError && 692: error.status === 403 && 693: error.message.includes('OAuth token has been revoked') 694: ) { 695: return createAssistantAPIErrorMessage({ 696: error: 'authentication_failed', 697: content: getTokenRevokedErrorMessage(), 698: }) 699: } 700: if ( 701: error instanceof APIError && 702: (error.status === 401 || error.status === 403) && 703: error.message.includes( 704: 'OAuth authentication is currently not allowed for this organization', 705: ) 706: ) { 707: return createAssistantAPIErrorMessage({ 708: error: 'authentication_failed', 709: content: getOauthOrgNotAllowedErrorMessage(), 710: }) 711: } 712: if ( 713: error instanceof APIError && 714: (error.status === 401 || error.status === 403) 715: ) { 716: if (isCCRMode()) { 717: return createAssistantAPIErrorMessage({ 718: error: 'authentication_failed', 719: content: CCR_AUTH_ERROR_MESSAGE, 720: }) 721: } 722: return createAssistantAPIErrorMessage({ 723: error: 'authentication_failed', 724: content: getIsNonInteractiveSession() 725: ? `Failed to authenticate. ${API_ERROR_MESSAGE_PREFIX}: ${error.message}` 726: : `Please run /login · ${API_ERROR_MESSAGE_PREFIX}: ${error.message}`, 727: }) 728: } 729: if ( 730: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && 731: error instanceof Error && 732: error.message.toLowerCase().includes('model id') 733: ) { 734: const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model' 735: const fallbackSuggestion = get3PModelFallbackSuggestion(model) 736: return createAssistantAPIErrorMessage({ 737: content: fallbackSuggestion 738: ? `${API_ERROR_MESSAGE_PREFIX} (${model}): ${error.message}. Try ${switchCmd} to switch to ${fallbackSuggestion}.` 739: : `${API_ERROR_MESSAGE_PREFIX} (${model}): ${error.message}. Run ${switchCmd} to pick a different model.`, 740: error: 'invalid_request', 741: }) 742: } 743: if (error instanceof APIError && error.status === 404) { 744: const switchCmd = getIsNonInteractiveSession() ? '--model' : '/model' 745: const fallbackSuggestion = get3PModelFallbackSuggestion(model) 746: return createAssistantAPIErrorMessage({ 747: content: fallbackSuggestion 748: ? `The model ${model} is not available on your ${getAPIProvider()} deployment. Try ${switchCmd} to switch to ${fallbackSuggestion}, or ask your admin to enable this model.` 749: : `There's an issue with the selected model (${model}). It may not exist or you may not have access to it. Run ${switchCmd} to pick a different model.`, 750: error: 'invalid_request', 751: }) 752: } 753: if (error instanceof APIConnectionError) { 754: return createAssistantAPIErrorMessage({ 755: content: `${API_ERROR_MESSAGE_PREFIX}: ${formatAPIError(error)}`, 756: error: 'unknown', 757: }) 758: } 759: if (error instanceof Error) { 760: return createAssistantAPIErrorMessage({ 761: content: `${API_ERROR_MESSAGE_PREFIX}: ${error.message}`, 762: error: 'unknown', 763: }) 764: } 765: return createAssistantAPIErrorMessage({ 766: content: API_ERROR_MESSAGE_PREFIX, 767: error: 'unknown', 768: }) 769: } 770: function get3PModelFallbackSuggestion(model: string): string | undefined { 771: if (getAPIProvider() === 'firstParty') { 772: return undefined 773: } 774: const m = model.toLowerCase() 775: if (m.includes('opus-4-6') || m.includes('opus_4_6')) { 776: return getModelStrings().opus41 777: } 778: if (m.includes('sonnet-4-6') || m.includes('sonnet_4_6')) { 779: return getModelStrings().sonnet45 780: } 781: if (m.includes('sonnet-4-5') || m.includes('sonnet_4_5')) { 782: return getModelStrings().sonnet40 783: } 784: return undefined 785: } 786: export function classifyAPIError(error: unknown): string { 787: if (error instanceof Error && error.message === 'Request was aborted.') { 788: return 'aborted' 789: } 790: if ( 791: error instanceof APIConnectionTimeoutError || 792: (error instanceof APIConnectionError && 793: error.message.toLowerCase().includes('timeout')) 794: ) { 795: return 'api_timeout' 796: } 797: if ( 798: error instanceof Error && 799: error.message.includes(REPEATED_529_ERROR_MESSAGE) 800: ) { 801: return 'repeated_529' 802: } 803: if ( 804: error instanceof Error && 805: error.message.includes(CUSTOM_OFF_SWITCH_MESSAGE) 806: ) { 807: return 'capacity_off_switch' 808: } 809: if (error instanceof APIError && error.status === 429) { 810: return 'rate_limit' 811: } 812: if ( 813: error instanceof APIError && 814: (error.status === 529 || 815: error.message?.includes('"type":"overloaded_error"')) 816: ) { 817: return 'server_overload' 818: } 819: if ( 820: error instanceof Error && 821: error.message 822: .toLowerCase() 823: .includes(PROMPT_TOO_LONG_ERROR_MESSAGE.toLowerCase()) 824: ) { 825: return 'prompt_too_long' 826: } 827: if ( 828: error instanceof Error && 829: /maximum of \d+ PDF pages/.test(error.message) 830: ) { 831: return 'pdf_too_large' 832: } 833: if ( 834: error instanceof Error && 835: error.message.includes('The PDF specified is password protected') 836: ) { 837: return 'pdf_password_protected' 838: } 839: if ( 840: error instanceof APIError && 841: error.status === 400 && 842: error.message.includes('image exceeds') && 843: error.message.includes('maximum') 844: ) { 845: return 'image_too_large' 846: } 847: if ( 848: error instanceof APIError && 849: error.status === 400 && 850: error.message.includes('image dimensions exceed') && 851: error.message.includes('many-image') 852: ) { 853: return 'image_too_large' 854: } 855: if ( 856: error instanceof APIError && 857: error.status === 400 && 858: error.message.includes( 859: '`tool_use` ids were found without `tool_result` blocks immediately after', 860: ) 861: ) { 862: return 'tool_use_mismatch' 863: } 864: if ( 865: error instanceof APIError && 866: error.status === 400 && 867: error.message.includes('unexpected `tool_use_id` found in `tool_result`') 868: ) { 869: return 'unexpected_tool_result' 870: } 871: if ( 872: error instanceof APIError && 873: error.status === 400 && 874: error.message.includes('`tool_use` ids must be unique') 875: ) { 876: return 'duplicate_tool_use_id' 877: } 878: if ( 879: error instanceof APIError && 880: error.status === 400 && 881: error.message.toLowerCase().includes('invalid model name') 882: ) { 883: return 'invalid_model' 884: } 885: if ( 886: error instanceof Error && 887: error.message 888: .toLowerCase() 889: .includes(CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE.toLowerCase()) 890: ) { 891: return 'credit_balance_low' 892: } 893: if ( 894: error instanceof Error && 895: error.message.toLowerCase().includes('x-api-key') 896: ) { 897: return 'invalid_api_key' 898: } 899: if ( 900: error instanceof APIError && 901: error.status === 403 && 902: error.message.includes('OAuth token has been revoked') 903: ) { 904: return 'token_revoked' 905: } 906: if ( 907: error instanceof APIError && 908: (error.status === 401 || error.status === 403) && 909: error.message.includes( 910: 'OAuth authentication is currently not allowed for this organization', 911: ) 912: ) { 913: return 'oauth_org_not_allowed' 914: } 915: if ( 916: error instanceof APIError && 917: (error.status === 401 || error.status === 403) 918: ) { 919: return 'auth_error' 920: } 921: if ( 922: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && 923: error instanceof Error && 924: error.message.toLowerCase().includes('model id') 925: ) { 926: return 'bedrock_model_access' 927: } 928: if (error instanceof APIError) { 929: const status = error.status 930: if (status >= 500) return 'server_error' 931: if (status >= 400) return 'client_error' 932: } 933: if (error instanceof APIConnectionError) { 934: const connectionDetails = extractConnectionErrorDetails(error) 935: if (connectionDetails?.isSSLError) { 936: return 'ssl_cert_error' 937: } 938: return 'connection_error' 939: } 940: return 'unknown' 941: } 942: export function categorizeRetryableAPIError( 943: error: APIError, 944: ): SDKAssistantMessageError { 945: if ( 946: error.status === 529 || 947: error.message?.includes('"type":"overloaded_error"') 948: ) { 949: return 'rate_limit' 950: } 951: if (error.status === 429) { 952: return 'rate_limit' 953: } 954: if (error.status === 401 || error.status === 403) { 955: return 'authentication_failed' 956: } 957: if (error.status !== undefined && error.status >= 408) { 958: return 'server_error' 959: } 960: return 'unknown' 961: } 962: export function getErrorMessageIfRefusal( 963: stopReason: BetaStopReason | null, 964: model: string, 965: ): AssistantMessage | undefined { 966: if (stopReason !== 'refusal') { 967: return 968: } 969: logEvent('tengu_refusal_api_response', {}) 970: const baseMessage = getIsNonInteractiveSession() 971: ? `${API_ERROR_MESSAGE_PREFIX}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Try rephrasing the request or attempting a different approach.` 972: : `${API_ERROR_MESSAGE_PREFIX}: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). Please double press esc to edit your last message or start a new session for Claude Code to assist with a different task.` 973: const modelSuggestion = 974: model !== 'claude-sonnet-4-20250514' 975: ? ' If you are seeing this refusal repeatedly, try running /model claude-sonnet-4-20250514 to switch models.' 976: : '' 977: return createAssistantAPIErrorMessage({ 978: content: baseMessage + modelSuggestion, 979: error: 'invalid_request', 980: }) 981: }

File: src/services/api/errorUtils.ts

typescript 1: import type { APIError } from '@anthropic-ai/sdk' 2: const SSL_ERROR_CODES = new Set([ 3: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 4: 'UNABLE_TO_GET_ISSUER_CERT', 5: 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 6: 'CERT_SIGNATURE_FAILURE', 7: 'CERT_NOT_YET_VALID', 8: 'CERT_HAS_EXPIRED', 9: 'CERT_REVOKED', 10: 'CERT_REJECTED', 11: 'CERT_UNTRUSTED', 12: 'DEPTH_ZERO_SELF_SIGNED_CERT', 13: 'SELF_SIGNED_CERT_IN_CHAIN', 14: 'CERT_CHAIN_TOO_LONG', 15: 'PATH_LENGTH_EXCEEDED', 16: 'ERR_TLS_CERT_ALTNAME_INVALID', 17: 'HOSTNAME_MISMATCH', 18: 'ERR_TLS_HANDSHAKE_TIMEOUT', 19: 'ERR_SSL_WRONG_VERSION_NUMBER', 20: 'ERR_SSL_DECRYPTION_FAILED_OR_BAD_RECORD_MAC', 21: ]) 22: export type ConnectionErrorDetails = { 23: code: string 24: message: string 25: isSSLError: boolean 26: } 27: export function extractConnectionErrorDetails( 28: error: unknown, 29: ): ConnectionErrorDetails | null { 30: if (!error || typeof error !== 'object') { 31: return null 32: } 33: let current: unknown = error 34: const maxDepth = 5 35: let depth = 0 36: while (current && depth < maxDepth) { 37: if ( 38: current instanceof Error && 39: 'code' in current && 40: typeof current.code === 'string' 41: ) { 42: const code = current.code 43: const isSSLError = SSL_ERROR_CODES.has(code) 44: return { 45: code, 46: message: current.message, 47: isSSLError, 48: } 49: } 50: if ( 51: current instanceof Error && 52: 'cause' in current && 53: current.cause !== current 54: ) { 55: current = current.cause 56: depth++ 57: } else { 58: break 59: } 60: } 61: return null 62: } 63: export function getSSLErrorHint(error: unknown): string | null { 64: const details = extractConnectionErrorDetails(error) 65: if (!details?.isSSLError) { 66: return null 67: } 68: return `SSL certificate error (${details.code}). If you are behind a corporate proxy or TLS-intercepting firewall, set NODE_EXTRA_CA_CERTS to your CA bundle path, or ask IT to allowlist *.anthropic.com. Run /doctor for details.` 69: } 70: function sanitizeMessageHTML(message: string): string { 71: if (message.includes('<!DOCTYPE html') || message.includes('<html')) { 72: const titleMatch = message.match(/<title>([^<]+)<\/title>/) 73: if (titleMatch && titleMatch[1]) { 74: return titleMatch[1].trim() 75: } 76: return '' 77: } 78: return message 79: } 80: /** 81: * Detects if an error message contains HTML content (e.g., CloudFlare error pages) 82: * and returns a user-friendly message instead 83: */ 84: export function sanitizeAPIError(apiError: APIError): string { 85: const message = apiError.message 86: if (!message) { 87: // Sometimes message is undefined 88: // TODO: figure out why 89: return '' 90: } 91: return sanitizeMessageHTML(message) 92: } 93: /** 94: * Shapes of deserialized API errors from session JSONL. 95: * 96: * After JSON round-tripping, the SDK's APIError loses its `.message` property. 97: * The actual message lives at different nesting levels depending on the provider: 98: * 99: * - Bedrock/proxy: `{ error: { message: "..." } }` 100: * - Standard Anthropic API: `{ error: { error: { message: "..." } } }` 101: * (the outer `.error` is the response body, the inner `.error` is the API error) 102: * 103: * See also: `getErrorMessage` in `logging.ts` which handles the same shapes. 104: */ 105: type NestedAPIError = { 106: error?: { 107: message?: string 108: error?: { message?: string } 109: } 110: } 111: function hasNestedError(value: unknown): value is NestedAPIError { 112: return ( 113: typeof value === 'object' && 114: value !== null && 115: 'error' in value && 116: typeof value.error === 'object' && 117: value.error !== null 118: ) 119: } 120: function extractNestedErrorMessage(error: APIError): string | null { 121: if (!hasNestedError(error)) { 122: return null 123: } 124: const narrowed: NestedAPIError = error 125: const nested = narrowed.error 126: const deepMsg = nested?.error?.message 127: if (typeof deepMsg === 'string' && deepMsg.length > 0) { 128: const sanitized = sanitizeMessageHTML(deepMsg) 129: if (sanitized.length > 0) { 130: return sanitized 131: } 132: } 133: const msg = nested?.message 134: if (typeof msg === 'string' && msg.length > 0) { 135: const sanitized = sanitizeMessageHTML(msg) 136: if (sanitized.length > 0) { 137: return sanitized 138: } 139: } 140: return null 141: } 142: export function formatAPIError(error: APIError): string { 143: const connectionDetails = extractConnectionErrorDetails(error) 144: if (connectionDetails) { 145: const { code, isSSLError } = connectionDetails 146: if (code === 'ETIMEDOUT') { 147: return 'Request timed out. Check your internet connection and proxy settings' 148: } 149: if (isSSLError) { 150: switch (code) { 151: case 'UNABLE_TO_VERIFY_LEAF_SIGNATURE': 152: case 'UNABLE_TO_GET_ISSUER_CERT': 153: case 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY': 154: return 'Unable to connect to API: SSL certificate verification failed. Check your proxy or corporate SSL certificates' 155: case 'CERT_HAS_EXPIRED': 156: return 'Unable to connect to API: SSL certificate has expired' 157: case 'CERT_REVOKED': 158: return 'Unable to connect to API: SSL certificate has been revoked' 159: case 'DEPTH_ZERO_SELF_SIGNED_CERT': 160: case 'SELF_SIGNED_CERT_IN_CHAIN': 161: return 'Unable to connect to API: Self-signed certificate detected. Check your proxy or corporate SSL certificates' 162: case 'ERR_TLS_CERT_ALTNAME_INVALID': 163: case 'HOSTNAME_MISMATCH': 164: return 'Unable to connect to API: SSL certificate hostname mismatch' 165: case 'CERT_NOT_YET_VALID': 166: return 'Unable to connect to API: SSL certificate is not yet valid' 167: default: 168: return `Unable to connect to API: SSL error (${code})` 169: } 170: } 171: } 172: if (error.message === 'Connection error.') { 173: if (connectionDetails?.code) { 174: return `Unable to connect to API (${connectionDetails.code})` 175: } 176: return 'Unable to connect to API. Check your internet connection' 177: } 178: if (!error.message) { 179: return ( 180: extractNestedErrorMessage(error) ?? 181: `API error (status ${error.status ?? 'unknown'})` 182: ) 183: } 184: const sanitizedMessage = sanitizeAPIError(error) 185: return sanitizedMessage !== error.message && sanitizedMessage.length > 0 186: ? sanitizedMessage 187: : error.message 188: }

File: src/services/api/filesApi.ts

typescript 1: import axios from 'axios' 2: import { randomUUID } from 'crypto' 3: import * as fs from 'fs/promises' 4: import * as path from 'path' 5: import { count } from '../../utils/array.js' 6: import { getCwd } from '../../utils/cwd.js' 7: import { logForDebugging } from '../../utils/debug.js' 8: import { errorMessage } from '../../utils/errors.js' 9: import { logError } from '../../utils/log.js' 10: import { sleep } from '../../utils/sleep.js' 11: import { 12: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 13: logEvent, 14: } from '../analytics/index.js' 15: const FILES_API_BETA_HEADER = 'files-api-2025-04-14,oauth-2025-04-20' 16: const ANTHROPIC_VERSION = '2023-06-01' 17: function getDefaultApiBaseUrl(): string { 18: return ( 19: process.env.ANTHROPIC_BASE_URL || 20: process.env.CLAUDE_CODE_API_BASE_URL || 21: 'https://api.anthropic.com' 22: ) 23: } 24: function logDebugError(message: string): void { 25: logForDebugging(`[files-api] ${message}`, { level: 'error' }) 26: } 27: function logDebug(message: string): void { 28: logForDebugging(`[files-api] ${message}`) 29: } 30: export type File = { 31: fileId: string 32: relativePath: string 33: } 34: export type FilesApiConfig = { 35: oauthToken: string 36: baseUrl?: string 37: sessionId: string 38: } 39: export type DownloadResult = { 40: fileId: string 41: path: string 42: success: boolean 43: error?: string 44: bytesWritten?: number 45: } 46: const MAX_RETRIES = 3 47: const BASE_DELAY_MS = 500 48: const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 49: type RetryResult<T> = { done: true; value: T } | { done: false; error?: string } 50: async function retryWithBackoff<T>( 51: operation: string, 52: attemptFn: (attempt: number) => Promise<RetryResult<T>>, 53: ): Promise<T> { 54: let lastError = '' 55: for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { 56: const result = await attemptFn(attempt) 57: if (result.done) { 58: return result.value 59: } 60: lastError = result.error || `${operation} failed` 61: logDebug( 62: `${operation} attempt ${attempt}/${MAX_RETRIES} failed: ${lastError}`, 63: ) 64: if (attempt < MAX_RETRIES) { 65: const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1) 66: logDebug(`Retrying ${operation} in ${delayMs}ms...`) 67: await sleep(delayMs) 68: } 69: } 70: throw new Error(`${lastError} after ${MAX_RETRIES} attempts`) 71: } 72: /** 73: * Downloads a single file from the Anthropic Public Files API 74: * 75: * @param fileId - The file ID (e.g., "file_011CNha8iCJcU1wXNR6q4V8w") 76: * @param config - Files API configuration 77: * @returns The file content as a Buffer 78: */ 79: export async function downloadFile( 80: fileId: string, 81: config: FilesApiConfig, 82: ): Promise<Buffer> { 83: const baseUrl = config.baseUrl || getDefaultApiBaseUrl() 84: const url = `${baseUrl}/v1/files/${fileId}/content` 85: const headers = { 86: Authorization: `Bearer ${config.oauthToken}`, 87: 'anthropic-version': ANTHROPIC_VERSION, 88: 'anthropic-beta': FILES_API_BETA_HEADER, 89: } 90: logDebug(`Downloading file ${fileId} from ${url}`) 91: return retryWithBackoff(`Download file ${fileId}`, async () => { 92: try { 93: const response = await axios.get(url, { 94: headers, 95: responseType: 'arraybuffer', 96: timeout: 60000, 97: validateStatus: status => status < 500, 98: }) 99: if (response.status === 200) { 100: logDebug(`Downloaded file ${fileId} (${response.data.length} bytes)`) 101: return { done: true, value: Buffer.from(response.data) } 102: } 103: if (response.status === 404) { 104: throw new Error(`File not found: ${fileId}`) 105: } 106: if (response.status === 401) { 107: throw new Error('Authentication failed: invalid or missing API key') 108: } 109: if (response.status === 403) { 110: throw new Error(`Access denied to file: ${fileId}`) 111: } 112: return { done: false, error: `status ${response.status}` } 113: } catch (error) { 114: if (!axios.isAxiosError(error)) { 115: throw error 116: } 117: return { done: false, error: error.message } 118: } 119: }) 120: } 121: export function buildDownloadPath( 122: basePath: string, 123: sessionId: string, 124: relativePath: string, 125: ): string | null { 126: const normalized = path.normalize(relativePath) 127: if (normalized.startsWith('..')) { 128: logDebugError( 129: `Invalid file path: ${relativePath}. Path must not traverse above workspace`, 130: ) 131: return null 132: } 133: const uploadsBase = path.join(basePath, sessionId, 'uploads') 134: const redundantPrefixes = [ 135: path.join(basePath, sessionId, 'uploads') + path.sep, 136: path.sep + 'uploads' + path.sep, 137: ] 138: const matchedPrefix = redundantPrefixes.find(p => normalized.startsWith(p)) 139: const cleanPath = matchedPrefix 140: ? normalized.slice(matchedPrefix.length) 141: : normalized 142: return path.join(uploadsBase, cleanPath) 143: } 144: export async function downloadAndSaveFile( 145: attachment: File, 146: config: FilesApiConfig, 147: ): Promise<DownloadResult> { 148: const { fileId, relativePath } = attachment 149: const fullPath = buildDownloadPath(getCwd(), config.sessionId, relativePath) 150: if (!fullPath) { 151: return { 152: fileId, 153: path: '', 154: success: false, 155: error: `Invalid file path: ${relativePath}`, 156: } 157: } 158: try { 159: // Download the file content 160: const content = await downloadFile(fileId, config) 161: // Ensure the parent directory exists 162: const parentDir = path.dirname(fullPath) 163: await fs.mkdir(parentDir, { recursive: true }) 164: // Write the file 165: await fs.writeFile(fullPath, content) 166: logDebug(`Saved file ${fileId} to ${fullPath} (${content.length} bytes)`) 167: return { 168: fileId, 169: path: fullPath, 170: success: true, 171: bytesWritten: content.length, 172: } 173: } catch (error) { 174: logDebugError(`Failed to download file ${fileId}: ${errorMessage(error)}`) 175: if (error instanceof Error) { 176: logError(error) 177: } 178: return { 179: fileId, 180: path: fullPath, 181: success: false, 182: error: errorMessage(error), 183: } 184: } 185: } 186: // Default concurrency limit for parallel downloads 187: const DEFAULT_CONCURRENCY = 5 188: /** 189: * Execute promises with limited concurrency 190: * 191: * @param items - Items to process 192: * @param fn - Async function to apply to each item 193: * @param concurrency - Maximum concurrent operations 194: * @returns Results in the same order as input items 195: */ 196: async function parallelWithLimit<T, R>( 197: items: T[], 198: fn: (item: T, index: number) => Promise<R>, 199: concurrency: number, 200: ): Promise<R[]> { 201: const results: R[] = new Array(items.length) 202: let currentIndex = 0 203: async function worker(): Promise<void> { 204: while (currentIndex < items.length) { 205: const index = currentIndex++ 206: const item = items[index] 207: if (item !== undefined) { 208: results[index] = await fn(item, index) 209: } 210: } 211: } 212: // Start workers up to the concurrency limit 213: const workers: Promise<void>[] = [] 214: const workerCount = Math.min(concurrency, items.length) 215: for (let i = 0; i < workerCount; i++) { 216: workers.push(worker()) 217: } 218: await Promise.all(workers) 219: return results 220: } 221: /** 222: * Downloads all file attachments for a session in parallel 223: * 224: * @param attachments - List of file attachments to download 225: * @param config - Files API configuration 226: * @param concurrency - Maximum concurrent downloads (default: 5) 227: * @returns Array of download results in the same order as input 228: */ 229: export async function downloadSessionFiles( 230: files: File[], 231: config: FilesApiConfig, 232: concurrency: number = DEFAULT_CONCURRENCY, 233: ): Promise<DownloadResult[]> { 234: if (files.length === 0) { 235: return [] 236: } 237: logDebug( 238: `Downloading ${files.length} file(s) for session ${config.sessionId}`, 239: ) 240: const startTime = Date.now() 241: // Download files in parallel with concurrency limit 242: const results = await parallelWithLimit( 243: files, 244: file => downloadAndSaveFile(file, config), 245: concurrency, 246: ) 247: const elapsedMs = Date.now() - startTime 248: const successCount = count(results, r => r.success) 249: logDebug( 250: `Downloaded ${successCount}/${files.length} file(s) in ${elapsedMs}ms`, 251: ) 252: return results 253: } 254: // ============================================================================ 255: // Upload Functions (BYOC mode) 256: // ============================================================================ 257: /** 258: * Result of a file upload operation 259: */ 260: export type UploadResult = 261: | { 262: path: string 263: fileId: string 264: size: number 265: success: true 266: } 267: | { 268: path: string 269: error: string 270: success: false 271: } 272: /** 273: * Upload a single file to the Files API (BYOC mode) 274: * 275: * Size validation is performed after reading the file to avoid TOCTOU race 276: * conditions where the file size could change between initial check and upload. 277: * 278: * @param filePath - Absolute path to the file to upload 279: * @param relativePath - Relative path for the file (used as filename in API) 280: * @param config - Files API configuration 281: * @returns Upload result with success/failure status 282: */ 283: export async function uploadFile( 284: filePath: string, 285: relativePath: string, 286: config: FilesApiConfig, 287: opts?: { signal?: AbortSignal }, 288: ): Promise<UploadResult> { 289: const baseUrl = config.baseUrl || getDefaultApiBaseUrl() 290: const url = `${baseUrl}/v1/files` 291: const headers = { 292: Authorization: `Bearer ${config.oauthToken}`, 293: 'anthropic-version': ANTHROPIC_VERSION, 294: 'anthropic-beta': FILES_API_BETA_HEADER, 295: } 296: logDebug(`Uploading file ${filePath} as ${relativePath}`) 297: let content: Buffer 298: try { 299: content = await fs.readFile(filePath) 300: } catch (error) { 301: logEvent('tengu_file_upload_failed', { 302: error_type: 303: 'file_read' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 304: }) 305: return { 306: path: relativePath, 307: error: errorMessage(error), 308: success: false, 309: } 310: } 311: const fileSize = content.length 312: if (fileSize > MAX_FILE_SIZE_BYTES) { 313: logEvent('tengu_file_upload_failed', { 314: error_type: 315: 'file_too_large' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 316: }) 317: return { 318: path: relativePath, 319: error: `File exceeds maximum size of ${MAX_FILE_SIZE_BYTES} bytes (actual: ${fileSize})`, 320: success: false, 321: } 322: } 323: const boundary = `----FormBoundary${randomUUID()}` 324: const filename = path.basename(relativePath) 325: const bodyParts: Buffer[] = [] 326: bodyParts.push( 327: Buffer.from( 328: `--${boundary}\r\n` + 329: `Content-Disposition: form-data; name="file"; filename="${filename}"\r\n` + 330: `Content-Type: application/octet-stream\r\n\r\n`, 331: ), 332: ) 333: bodyParts.push(content) 334: bodyParts.push(Buffer.from('\r\n')) 335: bodyParts.push( 336: Buffer.from( 337: `--${boundary}\r\n` + 338: `Content-Disposition: form-data; name="purpose"\r\n\r\n` + 339: `user_data\r\n`, 340: ), 341: ) 342: bodyParts.push(Buffer.from(`--${boundary}--\r\n`)) 343: const body = Buffer.concat(bodyParts) 344: try { 345: return await retryWithBackoff(`Upload file ${relativePath}`, async () => { 346: try { 347: const response = await axios.post(url, body, { 348: headers: { 349: ...headers, 350: 'Content-Type': `multipart/form-data; boundary=${boundary}`, 351: 'Content-Length': body.length.toString(), 352: }, 353: timeout: 120000, 354: signal: opts?.signal, 355: validateStatus: status => status < 500, 356: }) 357: if (response.status === 200 || response.status === 201) { 358: const fileId = response.data?.id 359: if (!fileId) { 360: return { 361: done: false, 362: error: 'Upload succeeded but no file ID returned', 363: } 364: } 365: logDebug(`Uploaded file ${filePath} -> ${fileId} (${fileSize} bytes)`) 366: return { 367: done: true, 368: value: { 369: path: relativePath, 370: fileId, 371: size: fileSize, 372: success: true as const, 373: }, 374: } 375: } 376: if (response.status === 401) { 377: logEvent('tengu_file_upload_failed', { 378: error_type: 379: 'auth' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 380: }) 381: throw new UploadNonRetriableError( 382: 'Authentication failed: invalid or missing API key', 383: ) 384: } 385: if (response.status === 403) { 386: logEvent('tengu_file_upload_failed', { 387: error_type: 388: 'forbidden' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 389: }) 390: throw new UploadNonRetriableError('Access denied for upload') 391: } 392: if (response.status === 413) { 393: logEvent('tengu_file_upload_failed', { 394: error_type: 395: 'size' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 396: }) 397: throw new UploadNonRetriableError('File too large for upload') 398: } 399: return { done: false, error: `status ${response.status}` } 400: } catch (error) { 401: if (error instanceof UploadNonRetriableError) { 402: throw error 403: } 404: if (axios.isCancel(error)) { 405: throw new UploadNonRetriableError('Upload canceled') 406: } 407: if (axios.isAxiosError(error)) { 408: return { done: false, error: error.message } 409: } 410: throw error 411: } 412: }) 413: } catch (error) { 414: if (error instanceof UploadNonRetriableError) { 415: return { 416: path: relativePath, 417: error: error.message, 418: success: false, 419: } 420: } 421: logEvent('tengu_file_upload_failed', { 422: error_type: 423: 'network' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 424: }) 425: return { 426: path: relativePath, 427: error: errorMessage(error), 428: success: false, 429: } 430: } 431: } 432: class UploadNonRetriableError extends Error { 433: constructor(message: string) { 434: super(message) 435: this.name = 'UploadNonRetriableError' 436: } 437: } 438: export async function uploadSessionFiles( 439: files: Array<{ path: string; relativePath: string }>, 440: config: FilesApiConfig, 441: concurrency: number = DEFAULT_CONCURRENCY, 442: ): Promise<UploadResult[]> { 443: if (files.length === 0) { 444: return [] 445: } 446: logDebug(`Uploading ${files.length} file(s) for session ${config.sessionId}`) 447: const startTime = Date.now() 448: const results = await parallelWithLimit( 449: files, 450: file => uploadFile(file.path, file.relativePath, config), 451: concurrency, 452: ) 453: const elapsedMs = Date.now() - startTime 454: const successCount = count(results, r => r.success) 455: logDebug(`Uploaded ${successCount}/${files.length} file(s) in ${elapsedMs}ms`) 456: return results 457: } 458: export type FileMetadata = { 459: filename: string 460: fileId: string 461: size: number 462: } 463: export async function listFilesCreatedAfter( 464: afterCreatedAt: string, 465: config: FilesApiConfig, 466: ): Promise<FileMetadata[]> { 467: const baseUrl = config.baseUrl || getDefaultApiBaseUrl() 468: const headers = { 469: Authorization: `Bearer ${config.oauthToken}`, 470: 'anthropic-version': ANTHROPIC_VERSION, 471: 'anthropic-beta': FILES_API_BETA_HEADER, 472: } 473: logDebug(`Listing files created after ${afterCreatedAt}`) 474: const allFiles: FileMetadata[] = [] 475: let afterId: string | undefined 476: while (true) { 477: const params: Record<string, string> = { 478: after_created_at: afterCreatedAt, 479: } 480: if (afterId) { 481: params.after_id = afterId 482: } 483: const page = await retryWithBackoff( 484: `List files after ${afterCreatedAt}`, 485: async () => { 486: try { 487: const response = await axios.get(`${baseUrl}/v1/files`, { 488: headers, 489: params, 490: timeout: 60000, 491: validateStatus: status => status < 500, 492: }) 493: if (response.status === 200) { 494: return { done: true, value: response.data } 495: } 496: if (response.status === 401) { 497: logEvent('tengu_file_list_failed', { 498: error_type: 499: 'auth' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 500: }) 501: throw new Error('Authentication failed: invalid or missing API key') 502: } 503: if (response.status === 403) { 504: logEvent('tengu_file_list_failed', { 505: error_type: 506: 'forbidden' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 507: }) 508: throw new Error('Access denied to list files') 509: } 510: return { done: false, error: `status ${response.status}` } 511: } catch (error) { 512: if (!axios.isAxiosError(error)) { 513: throw error 514: } 515: logEvent('tengu_file_list_failed', { 516: error_type: 517: 'network' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 518: }) 519: return { done: false, error: error.message } 520: } 521: }, 522: ) 523: const files = page.data || [] 524: for (const f of files) { 525: allFiles.push({ 526: filename: f.filename, 527: fileId: f.id, 528: size: f.size_bytes, 529: }) 530: } 531: if (!page.has_more) { 532: break 533: } 534: const lastFile = files.at(-1) 535: if (!lastFile?.id) { 536: break 537: } 538: afterId = lastFile.id 539: } 540: logDebug(`Listed ${allFiles.length} files created after ${afterCreatedAt}`) 541: return allFiles 542: } 543: export function parseFileSpecs(fileSpecs: string[]): File[] { 544: const files: File[] = [] 545: const expandedSpecs = fileSpecs.flatMap(s => s.split(' ').filter(Boolean)) 546: for (const spec of expandedSpecs) { 547: const colonIndex = spec.indexOf(':') 548: if (colonIndex === -1) { 549: continue 550: } 551: const fileId = spec.substring(0, colonIndex) 552: const relativePath = spec.substring(colonIndex + 1) 553: if (!fileId || !relativePath) { 554: logDebugError( 555: `Invalid file spec: ${spec}. Both file_id and path are required`, 556: ) 557: continue 558: } 559: files.push({ fileId, relativePath }) 560: } 561: return files 562: }

File: src/services/api/firstTokenDate.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 4: import { getAuthHeaders } from '../../utils/http.js' 5: import { logError } from '../../utils/log.js' 6: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 7: export async function fetchAndStoreClaudeCodeFirstTokenDate(): Promise<void> { 8: try { 9: const config = getGlobalConfig() 10: if (config.claudeCodeFirstTokenDate !== undefined) { 11: return 12: } 13: const authHeaders = getAuthHeaders() 14: if (authHeaders.error) { 15: logError(new Error(`Failed to get auth headers: ${authHeaders.error}`)) 16: return 17: } 18: const oauthConfig = getOauthConfig() 19: const url = `${oauthConfig.BASE_API_URL}/api/organization/claude_code_first_token_date` 20: const response = await axios.get(url, { 21: headers: { 22: ...authHeaders.headers, 23: 'User-Agent': getClaudeCodeUserAgent(), 24: }, 25: timeout: 10000, 26: }) 27: const firstTokenDate = response.data?.first_token_date ?? null 28: if (firstTokenDate !== null) { 29: const dateTime = new Date(firstTokenDate).getTime() 30: if (isNaN(dateTime)) { 31: logError( 32: new Error( 33: `Received invalid first_token_date from API: ${firstTokenDate}`, 34: ), 35: ) 36: return 37: } 38: } 39: saveGlobalConfig(current => ({ 40: ...current, 41: claudeCodeFirstTokenDate: firstTokenDate, 42: })) 43: } catch (error) { 44: logError(error) 45: } 46: }

File: src/services/api/grove.ts

typescript 1: import axios from 'axios' 2: import memoize from 'lodash-es/memoize.js' 3: import { 4: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5: logEvent, 6: } from 'src/services/analytics/index.js' 7: import { getOauthAccountInfo, isConsumerSubscriber } from 'src/utils/auth.js' 8: import { logForDebugging } from 'src/utils/debug.js' 9: import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' 10: import { isEssentialTrafficOnly } from 'src/utils/privacyLevel.js' 11: import { writeToStderr } from 'src/utils/process.js' 12: import { getOauthConfig } from '../../constants/oauth.js' 13: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 14: import { 15: getAuthHeaders, 16: getUserAgent, 17: withOAuth401Retry, 18: } from '../../utils/http.js' 19: import { logError } from '../../utils/log.js' 20: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 21: const GROVE_CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000 22: export type AccountSettings = { 23: grove_enabled: boolean | null 24: grove_notice_viewed_at: string | null 25: } 26: export type GroveConfig = { 27: grove_enabled: boolean 28: domain_excluded: boolean 29: notice_is_grace_period: boolean 30: notice_reminder_frequency: number | null 31: } 32: export type ApiResult<T> = { success: true; data: T } | { success: false } 33: export const getGroveSettings = memoize( 34: async (): Promise<ApiResult<AccountSettings>> => { 35: if (isEssentialTrafficOnly()) { 36: return { success: false } 37: } 38: try { 39: const response = await withOAuth401Retry(() => { 40: const authHeaders = getAuthHeaders() 41: if (authHeaders.error) { 42: throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 43: } 44: return axios.get<AccountSettings>( 45: `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, 46: { 47: headers: { 48: ...authHeaders.headers, 49: 'User-Agent': getClaudeCodeUserAgent(), 50: }, 51: }, 52: ) 53: }) 54: return { success: true, data: response.data } 55: } catch (err) { 56: logError(err) 57: getGroveSettings.cache.clear?.() 58: return { success: false } 59: } 60: }, 61: ) 62: export async function markGroveNoticeViewed(): Promise<void> { 63: try { 64: await withOAuth401Retry(() => { 65: const authHeaders = getAuthHeaders() 66: if (authHeaders.error) { 67: throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 68: } 69: return axios.post( 70: `${getOauthConfig().BASE_API_URL}/api/oauth/account/grove_notice_viewed`, 71: {}, 72: { 73: headers: { 74: ...authHeaders.headers, 75: 'User-Agent': getClaudeCodeUserAgent(), 76: }, 77: }, 78: ) 79: }) 80: getGroveSettings.cache.clear?.() 81: } catch (err) { 82: logError(err) 83: } 84: } 85: export async function updateGroveSettings( 86: groveEnabled: boolean, 87: ): Promise<void> { 88: try { 89: await withOAuth401Retry(() => { 90: const authHeaders = getAuthHeaders() 91: if (authHeaders.error) { 92: throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 93: } 94: return axios.patch( 95: `${getOauthConfig().BASE_API_URL}/api/oauth/account/settings`, 96: { 97: grove_enabled: groveEnabled, 98: }, 99: { 100: headers: { 101: ...authHeaders.headers, 102: 'User-Agent': getClaudeCodeUserAgent(), 103: }, 104: }, 105: ) 106: }) 107: getGroveSettings.cache.clear?.() 108: } catch (err) { 109: logError(err) 110: } 111: } 112: export async function isQualifiedForGrove(): Promise<boolean> { 113: if (!isConsumerSubscriber()) { 114: return false 115: } 116: const accountId = getOauthAccountInfo()?.accountUuid 117: if (!accountId) { 118: return false 119: } 120: const globalConfig = getGlobalConfig() 121: const cachedEntry = globalConfig.groveConfigCache?.[accountId] 122: const now = Date.now() 123: if (!cachedEntry) { 124: logForDebugging( 125: 'Grove: No cache, fetching config in background (dialog skipped this session)', 126: ) 127: void fetchAndStoreGroveConfig(accountId) 128: return false 129: } 130: if (now - cachedEntry.timestamp > GROVE_CACHE_EXPIRATION_MS) { 131: logForDebugging( 132: 'Grove: Cache stale, returning cached data and refreshing in background', 133: ) 134: void fetchAndStoreGroveConfig(accountId) 135: return cachedEntry.grove_enabled 136: } 137: logForDebugging('Grove: Using fresh cached config') 138: return cachedEntry.grove_enabled 139: } 140: async function fetchAndStoreGroveConfig(accountId: string): Promise<void> { 141: try { 142: const result = await getGroveNoticeConfig() 143: if (!result.success) { 144: return 145: } 146: const groveEnabled = result.data.grove_enabled 147: const cachedEntry = getGlobalConfig().groveConfigCache?.[accountId] 148: if ( 149: cachedEntry?.grove_enabled === groveEnabled && 150: Date.now() - cachedEntry.timestamp <= GROVE_CACHE_EXPIRATION_MS 151: ) { 152: return 153: } 154: saveGlobalConfig(current => ({ 155: ...current, 156: groveConfigCache: { 157: ...current.groveConfigCache, 158: [accountId]: { 159: grove_enabled: groveEnabled, 160: timestamp: Date.now(), 161: }, 162: }, 163: })) 164: } catch (err) { 165: logForDebugging(`Grove: Failed to fetch and store config: ${err}`) 166: } 167: } 168: export const getGroveNoticeConfig = memoize( 169: async (): Promise<ApiResult<GroveConfig>> => { 170: if (isEssentialTrafficOnly()) { 171: return { success: false } 172: } 173: try { 174: const response = await withOAuth401Retry(() => { 175: const authHeaders = getAuthHeaders() 176: if (authHeaders.error) { 177: throw new Error(`Failed to get auth headers: ${authHeaders.error}`) 178: } 179: return axios.get<GroveConfig>( 180: `${getOauthConfig().BASE_API_URL}/api/claude_code_grove`, 181: { 182: headers: { 183: ...authHeaders.headers, 184: 'User-Agent': getUserAgent(), 185: }, 186: timeout: 3000, 187: }, 188: ) 189: }) 190: const { 191: grove_enabled, 192: domain_excluded, 193: notice_is_grace_period, 194: notice_reminder_frequency, 195: } = response.data 196: return { 197: success: true, 198: data: { 199: grove_enabled, 200: domain_excluded: domain_excluded ?? false, 201: notice_is_grace_period: notice_is_grace_period ?? true, 202: notice_reminder_frequency, 203: }, 204: } 205: } catch (err) { 206: logForDebugging(`Failed to fetch Grove notice config: ${err}`) 207: return { success: false } 208: } 209: }, 210: ) 211: export function calculateShouldShowGrove( 212: settingsResult: ApiResult<AccountSettings>, 213: configResult: ApiResult<GroveConfig>, 214: showIfAlreadyViewed: boolean, 215: ): boolean { 216: if (!settingsResult.success || !configResult.success) { 217: return false 218: } 219: const settings = settingsResult.data 220: const config = configResult.data 221: const hasChosen = settings.grove_enabled !== null 222: if (hasChosen) { 223: return false 224: } 225: if (showIfAlreadyViewed) { 226: return true 227: } 228: if (!config.notice_is_grace_period) { 229: return true 230: } 231: const reminderFrequency = config.notice_reminder_frequency 232: if (reminderFrequency !== null && settings.grove_notice_viewed_at) { 233: const daysSinceViewed = Math.floor( 234: (Date.now() - new Date(settings.grove_notice_viewed_at).getTime()) / 235: (1000 * 60 * 60 * 24), 236: ) 237: return daysSinceViewed >= reminderFrequency 238: } else { 239: const viewedAt = settings.grove_notice_viewed_at 240: return viewedAt === null || viewedAt === undefined 241: } 242: } 243: export async function checkGroveForNonInteractive(): Promise<void> { 244: const [settingsResult, configResult] = await Promise.all([ 245: getGroveSettings(), 246: getGroveNoticeConfig(), 247: ]) 248: const shouldShowGrove = calculateShouldShowGrove( 249: settingsResult, 250: configResult, 251: false, 252: ) 253: if (shouldShowGrove) { 254: const config = configResult.success ? configResult.data : null 255: logEvent('tengu_grove_print_viewed', { 256: dismissable: 257: config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 258: }) 259: if (config === null || config.notice_is_grace_period) { 260: writeToStderr( 261: '\nAn update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. Run `claude` to review the updated terms.\n\n', 262: ) 263: await markGroveNoticeViewed() 264: } else { 265: writeToStderr( 266: '\n[ACTION REQUIRED] An update to our Consumer Terms and Privacy Policy has taken effect on October 8, 2025. You must run `claude` to review the updated terms.\n\n', 267: ) 268: await gracefulShutdown(1) 269: } 270: } 271: }

File: src/services/api/logging.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { APIError } from '@anthropic-ai/sdk' 3: import type { 4: BetaStopReason, 5: BetaUsage as Usage, 6: } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 7: import { 8: addToTotalDurationState, 9: consumePostCompaction, 10: getIsNonInteractiveSession, 11: getLastApiCompletionTimestamp, 12: getTeleportedSessionInfo, 13: markFirstTeleportMessageLogged, 14: setLastApiCompletionTimestamp, 15: } from 'src/bootstrap/state.js' 16: import type { QueryChainTracking } from 'src/Tool.js' 17: import { isConnectorTextBlock } from 'src/types/connectorText.js' 18: import type { AssistantMessage } from 'src/types/message.js' 19: import { logForDebugging } from 'src/utils/debug.js' 20: import type { EffortLevel } from 'src/utils/effort.js' 21: import { logError } from 'src/utils/log.js' 22: import { getAPIProviderForStatsig } from 'src/utils/model/providers.js' 23: import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js' 24: import { jsonStringify } from 'src/utils/slowOperations.js' 25: import { logOTelEvent } from 'src/utils/telemetry/events.js' 26: import { 27: endLLMRequestSpan, 28: isBetaTracingEnabled, 29: type Span, 30: } from 'src/utils/telemetry/sessionTracing.js' 31: import type { NonNullableUsage } from '../../entrypoints/sdk/sdkUtilityTypes.js' 32: import { consumeInvokingRequestId } from '../../utils/agentContext.js' 33: import { 34: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 35: logEvent, 36: } from '../analytics/index.js' 37: import { sanitizeToolNameForAnalytics } from '../analytics/metadata.js' 38: import { EMPTY_USAGE } from './emptyUsage.js' 39: import { classifyAPIError } from './errors.js' 40: import { extractConnectionErrorDetails } from './errorUtils.js' 41: export type { NonNullableUsage } 42: export { EMPTY_USAGE } 43: export type GlobalCacheStrategy = 'tool_based' | 'system_prompt' | 'none' 44: function getErrorMessage(error: unknown): string { 45: if (error instanceof APIError) { 46: const body = error.error as { error?: { message?: string } } | undefined 47: if (body?.error?.message) return body.error.message 48: } 49: return error instanceof Error ? error.message : String(error) 50: } 51: type KnownGateway = 52: | 'litellm' 53: | 'helicone' 54: | 'portkey' 55: | 'cloudflare-ai-gateway' 56: | 'kong' 57: | 'braintrust' 58: | 'databricks' 59: const GATEWAY_FINGERPRINTS: Partial< 60: Record<KnownGateway, { prefixes: string[] }> 61: > = { 62: litellm: { 63: prefixes: ['x-litellm-'], 64: }, 65: helicone: { 66: prefixes: ['helicone-'], 67: }, 68: portkey: { 69: prefixes: ['x-portkey-'], 70: }, 71: 'cloudflare-ai-gateway': { 72: prefixes: ['cf-aig-'], 73: }, 74: kong: { 75: prefixes: ['x-kong-'], 76: }, 77: braintrust: { 78: prefixes: ['x-bt-'], 79: }, 80: } 81: const GATEWAY_HOST_SUFFIXES: Partial<Record<KnownGateway, string[]>> = { 82: databricks: [ 83: '.cloud.databricks.com', 84: '.azuredatabricks.net', 85: '.gcp.databricks.com', 86: ], 87: } 88: function detectGateway({ 89: headers, 90: baseUrl, 91: }: { 92: headers?: globalThis.Headers 93: baseUrl?: string 94: }): KnownGateway | undefined { 95: if (headers) { 96: const headerNames: string[] = [] 97: headers.forEach((_, key) => headerNames.push(key)) 98: for (const [gw, { prefixes }] of Object.entries(GATEWAY_FINGERPRINTS)) { 99: if (prefixes.some(p => headerNames.some(h => h.startsWith(p)))) { 100: return gw as KnownGateway 101: } 102: } 103: } 104: if (baseUrl) { 105: try { 106: const host = new URL(baseUrl).hostname.toLowerCase() 107: for (const [gw, suffixes] of Object.entries(GATEWAY_HOST_SUFFIXES)) { 108: if (suffixes.some(s => host.endsWith(s))) { 109: return gw as KnownGateway 110: } 111: } 112: } catch { 113: } 114: } 115: return undefined 116: } 117: function getAnthropicEnvMetadata() { 118: return { 119: ...(process.env.ANTHROPIC_BASE_URL 120: ? { 121: baseUrl: process.env 122: .ANTHROPIC_BASE_URL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 123: } 124: : {}), 125: ...(process.env.ANTHROPIC_MODEL 126: ? { 127: envModel: process.env 128: .ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 129: } 130: : {}), 131: ...(process.env.ANTHROPIC_SMALL_FAST_MODEL 132: ? { 133: envSmallFastModel: process.env 134: .ANTHROPIC_SMALL_FAST_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 135: } 136: : {}), 137: } 138: } 139: function getBuildAgeMinutes(): number | undefined { 140: if (!MACRO.BUILD_TIME) return undefined 141: const buildTime = new Date(MACRO.BUILD_TIME).getTime() 142: if (isNaN(buildTime)) return undefined 143: return Math.floor((Date.now() - buildTime) / 60000) 144: } 145: export function logAPIQuery({ 146: model, 147: messagesLength, 148: temperature, 149: betas, 150: permissionMode, 151: querySource, 152: queryTracking, 153: thinkingType, 154: effortValue, 155: fastMode, 156: previousRequestId, 157: }: { 158: model: string 159: messagesLength: number 160: temperature: number 161: betas?: string[] 162: permissionMode?: PermissionMode 163: querySource: string 164: queryTracking?: QueryChainTracking 165: thinkingType?: 'adaptive' | 'enabled' | 'disabled' 166: effortValue?: EffortLevel | null 167: fastMode?: boolean 168: previousRequestId?: string | null 169: }): void { 170: logEvent('tengu_api_query', { 171: model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 172: messagesLength, 173: temperature: temperature, 174: provider: getAPIProviderForStatsig(), 175: buildAgeMins: getBuildAgeMinutes(), 176: ...(betas?.length 177: ? { 178: betas: betas.join( 179: ',', 180: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 181: } 182: : {}), 183: permissionMode: 184: permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 185: querySource: 186: querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 187: ...(queryTracking 188: ? { 189: queryChainId: 190: queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 191: queryDepth: queryTracking.depth, 192: } 193: : {}), 194: thinkingType: 195: thinkingType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 196: effortValue: 197: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 198: fastMode, 199: ...(previousRequestId 200: ? { 201: previousRequestId: 202: previousRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 203: } 204: : {}), 205: ...getAnthropicEnvMetadata(), 206: }) 207: } 208: export function logAPIError({ 209: error, 210: model, 211: messageCount, 212: messageTokens, 213: durationMs, 214: durationMsIncludingRetries, 215: attempt, 216: requestId, 217: clientRequestId, 218: didFallBackToNonStreaming, 219: promptCategory, 220: headers, 221: queryTracking, 222: querySource, 223: llmSpan, 224: fastMode, 225: previousRequestId, 226: }: { 227: error: unknown 228: model: string 229: messageCount: number 230: messageTokens?: number 231: durationMs: number 232: durationMsIncludingRetries: number 233: attempt: number 234: requestId?: string | null 235: clientRequestId?: string 236: didFallBackToNonStreaming?: boolean 237: promptCategory?: string 238: headers?: globalThis.Headers 239: queryTracking?: QueryChainTracking 240: querySource?: string 241: llmSpan?: Span 242: fastMode?: boolean 243: previousRequestId?: string | null 244: }): void { 245: const gateway = detectGateway({ 246: headers: 247: error instanceof APIError && error.headers ? error.headers : headers, 248: baseUrl: process.env.ANTHROPIC_BASE_URL, 249: }) 250: const errStr = getErrorMessage(error) 251: const status = error instanceof APIError ? String(error.status) : undefined 252: const errorType = classifyAPIError(error) 253: const connectionDetails = extractConnectionErrorDetails(error) 254: if (connectionDetails) { 255: const sslLabel = connectionDetails.isSSLError ? ' (SSL error)' : '' 256: logForDebugging( 257: `Connection error details: code=${connectionDetails.code}${sslLabel}, message=${connectionDetails.message}`, 258: { level: 'error' }, 259: ) 260: } 261: const invocation = consumeInvokingRequestId() 262: if (clientRequestId) { 263: logForDebugging( 264: `API error x-client-request-id=${clientRequestId} (give this to the API team for server-log lookup)`, 265: { level: 'error' }, 266: ) 267: } 268: logError(error as Error) 269: logEvent('tengu_api_error', { 270: model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 271: error: errStr as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 272: status: 273: status as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 274: errorType: 275: errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 276: messageCount, 277: messageTokens, 278: durationMs, 279: durationMsIncludingRetries, 280: attempt, 281: provider: getAPIProviderForStatsig(), 282: requestId: 283: (requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) || 284: undefined, 285: ...(invocation 286: ? { 287: invokingRequestId: 288: invocation.invokingRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 289: invocationKind: 290: invocation.invocationKind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 291: } 292: : {}), 293: clientRequestId: 294: (clientRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) || 295: undefined, 296: didFallBackToNonStreaming, 297: ...(promptCategory 298: ? { 299: promptCategory: 300: promptCategory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 301: } 302: : {}), 303: ...(gateway 304: ? { 305: gateway: 306: gateway as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 307: } 308: : {}), 309: ...(queryTracking 310: ? { 311: queryChainId: 312: queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 313: queryDepth: queryTracking.depth, 314: } 315: : {}), 316: ...(querySource 317: ? { 318: querySource: 319: querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 320: } 321: : {}), 322: fastMode, 323: ...(previousRequestId 324: ? { 325: previousRequestId: 326: previousRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 327: } 328: : {}), 329: ...getAnthropicEnvMetadata(), 330: }) 331: void logOTelEvent('api_error', { 332: model: model, 333: error: errStr, 334: status_code: String(status), 335: duration_ms: String(durationMs), 336: attempt: String(attempt), 337: speed: fastMode ? 'fast' : 'normal', 338: }) 339: endLLMRequestSpan(llmSpan, { 340: success: false, 341: statusCode: status ? parseInt(status) : undefined, 342: error: errStr, 343: attempt, 344: }) 345: const teleportInfo = getTeleportedSessionInfo() 346: if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) { 347: logEvent('tengu_teleport_first_message_error', { 348: session_id: 349: teleportInfo.sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 350: error_type: 351: errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 352: }) 353: markFirstTeleportMessageLogged() 354: } 355: } 356: function logAPISuccess({ 357: model, 358: preNormalizedModel, 359: messageCount, 360: messageTokens, 361: usage, 362: durationMs, 363: durationMsIncludingRetries, 364: attempt, 365: ttftMs, 366: requestId, 367: stopReason, 368: costUSD, 369: didFallBackToNonStreaming, 370: querySource, 371: gateway, 372: queryTracking, 373: permissionMode, 374: globalCacheStrategy, 375: textContentLength, 376: thinkingContentLength, 377: toolUseContentLengths, 378: connectorTextBlockCount, 379: fastMode, 380: previousRequestId, 381: betas, 382: }: { 383: model: string 384: preNormalizedModel: string 385: messageCount: number 386: messageTokens: number 387: usage: Usage 388: durationMs: number 389: durationMsIncludingRetries: number 390: attempt: number 391: ttftMs: number | null 392: requestId: string | null 393: stopReason: BetaStopReason | null 394: costUSD: number 395: didFallBackToNonStreaming: boolean 396: querySource: string 397: gateway?: KnownGateway 398: queryTracking?: QueryChainTracking 399: permissionMode?: PermissionMode 400: globalCacheStrategy?: GlobalCacheStrategy 401: textContentLength?: number 402: thinkingContentLength?: number 403: toolUseContentLengths?: Record<string, number> 404: connectorTextBlockCount?: number 405: fastMode?: boolean 406: previousRequestId?: string | null 407: betas?: string[] 408: }): void { 409: const isNonInteractiveSession = getIsNonInteractiveSession() 410: const isPostCompaction = consumePostCompaction() 411: const hasPrintFlag = 412: process.argv.includes('-p') || process.argv.includes('--print') 413: const now = Date.now() 414: const lastCompletion = getLastApiCompletionTimestamp() 415: const timeSinceLastApiCallMs = 416: lastCompletion !== null ? now - lastCompletion : undefined 417: const invocation = consumeInvokingRequestId() 418: logEvent('tengu_api_success', { 419: model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 420: ...(preNormalizedModel !== model 421: ? { 422: preNormalizedModel: 423: preNormalizedModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 424: } 425: : {}), 426: ...(betas?.length 427: ? { 428: betas: betas.join( 429: ',', 430: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 431: } 432: : {}), 433: messageCount, 434: messageTokens, 435: inputTokens: usage.input_tokens, 436: outputTokens: usage.output_tokens, 437: cachedInputTokens: usage.cache_read_input_tokens ?? 0, 438: uncachedInputTokens: usage.cache_creation_input_tokens ?? 0, 439: durationMs: durationMs, 440: durationMsIncludingRetries: durationMsIncludingRetries, 441: attempt: attempt, 442: ttftMs: ttftMs ?? undefined, 443: buildAgeMins: getBuildAgeMinutes(), 444: provider: getAPIProviderForStatsig(), 445: requestId: 446: (requestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) ?? 447: undefined, 448: ...(invocation 449: ? { 450: invokingRequestId: 451: invocation.invokingRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 452: invocationKind: 453: invocation.invocationKind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 454: } 455: : {}), 456: stop_reason: 457: (stopReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) ?? 458: undefined, 459: costUSD, 460: didFallBackToNonStreaming, 461: isNonInteractiveSession, 462: print: hasPrintFlag, 463: isTTY: process.stdout.isTTY ?? false, 464: querySource: 465: querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 466: ...(gateway 467: ? { 468: gateway: 469: gateway as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 470: } 471: : {}), 472: ...(queryTracking 473: ? { 474: queryChainId: 475: queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 476: queryDepth: queryTracking.depth, 477: } 478: : {}), 479: permissionMode: 480: permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 481: ...(globalCacheStrategy 482: ? { 483: globalCacheStrategy: 484: globalCacheStrategy as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 485: } 486: : {}), 487: ...(textContentLength !== undefined 488: ? ({ 489: textContentLength, 490: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 491: : {}), 492: ...(thinkingContentLength !== undefined 493: ? ({ 494: thinkingContentLength, 495: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 496: : {}), 497: ...(toolUseContentLengths !== undefined 498: ? ({ 499: toolUseContentLengths: jsonStringify( 500: toolUseContentLengths, 501: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 502: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 503: : {}), 504: ...(connectorTextBlockCount !== undefined 505: ? ({ 506: connectorTextBlockCount, 507: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 508: : {}), 509: fastMode, 510: ...(feature('CACHED_MICROCOMPACT') && 511: ((usage as unknown as { cache_deleted_input_tokens?: number }) 512: .cache_deleted_input_tokens ?? 0) > 0 513: ? { 514: cacheDeletedInputTokens: ( 515: usage as unknown as { cache_deleted_input_tokens: number } 516: ).cache_deleted_input_tokens, 517: } 518: : {}), 519: ...(previousRequestId 520: ? { 521: previousRequestId: 522: previousRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 523: } 524: : {}), 525: ...(isPostCompaction ? { isPostCompaction } : {}), 526: ...getAnthropicEnvMetadata(), 527: timeSinceLastApiCallMs, 528: }) 529: setLastApiCompletionTimestamp(now) 530: } 531: export function logAPISuccessAndDuration({ 532: model, 533: preNormalizedModel, 534: start, 535: startIncludingRetries, 536: ttftMs, 537: usage, 538: attempt, 539: messageCount, 540: messageTokens, 541: requestId, 542: stopReason, 543: didFallBackToNonStreaming, 544: querySource, 545: headers, 546: costUSD, 547: queryTracking, 548: permissionMode, 549: newMessages, 550: llmSpan, 551: globalCacheStrategy, 552: requestSetupMs, 553: attemptStartTimes, 554: fastMode, 555: previousRequestId, 556: betas, 557: }: { 558: model: string 559: preNormalizedModel: string 560: start: number 561: startIncludingRetries: number 562: ttftMs: number | null 563: usage: NonNullableUsage 564: attempt: number 565: messageCount: number 566: messageTokens: number 567: requestId: string | null 568: stopReason: BetaStopReason | null 569: didFallBackToNonStreaming: boolean 570: querySource: string 571: headers?: globalThis.Headers 572: costUSD: number 573: queryTracking?: QueryChainTracking 574: permissionMode?: PermissionMode 575: newMessages?: AssistantMessage[] 576: llmSpan?: Span 577: globalCacheStrategy?: GlobalCacheStrategy 578: requestSetupMs?: number 579: attemptStartTimes?: number[] 580: fastMode?: boolean 581: previousRequestId?: string | null 582: betas?: string[] 583: }): void { 584: const gateway = detectGateway({ 585: headers, 586: baseUrl: process.env.ANTHROPIC_BASE_URL, 587: }) 588: let textContentLength: number | undefined 589: let thinkingContentLength: number | undefined 590: let toolUseContentLengths: Record<string, number> | undefined 591: let connectorTextBlockCount: number | undefined 592: if (newMessages) { 593: let textLen = 0 594: let thinkingLen = 0 595: let hasToolUse = false 596: const toolLengths: Record<string, number> = {} 597: let connectorCount = 0 598: for (const msg of newMessages) { 599: for (const block of msg.message.content) { 600: if (block.type === 'text') { 601: textLen += block.text.length 602: } else if (feature('CONNECTOR_TEXT') && isConnectorTextBlock(block)) { 603: connectorCount++ 604: } else if (block.type === 'thinking') { 605: thinkingLen += block.thinking.length 606: } else if ( 607: block.type === 'tool_use' || 608: block.type === 'server_tool_use' || 609: block.type === 'mcp_tool_use' 610: ) { 611: const inputLen = jsonStringify(block.input).length 612: const sanitizedName = sanitizeToolNameForAnalytics(block.name) 613: toolLengths[sanitizedName] = 614: (toolLengths[sanitizedName] ?? 0) + inputLen 615: hasToolUse = true 616: } 617: } 618: } 619: textContentLength = textLen 620: thinkingContentLength = thinkingLen > 0 ? thinkingLen : undefined 621: toolUseContentLengths = hasToolUse ? toolLengths : undefined 622: connectorTextBlockCount = connectorCount > 0 ? connectorCount : undefined 623: } 624: const durationMs = Date.now() - start 625: const durationMsIncludingRetries = Date.now() - startIncludingRetries 626: addToTotalDurationState(durationMsIncludingRetries, durationMs) 627: logAPISuccess({ 628: model, 629: preNormalizedModel, 630: messageCount, 631: messageTokens, 632: usage, 633: durationMs, 634: durationMsIncludingRetries, 635: attempt, 636: ttftMs, 637: requestId, 638: stopReason, 639: costUSD, 640: didFallBackToNonStreaming, 641: querySource, 642: gateway, 643: queryTracking, 644: permissionMode, 645: globalCacheStrategy, 646: textContentLength, 647: thinkingContentLength, 648: toolUseContentLengths, 649: connectorTextBlockCount, 650: fastMode, 651: previousRequestId, 652: betas, 653: }) 654: void logOTelEvent('api_request', { 655: model, 656: input_tokens: String(usage.input_tokens), 657: output_tokens: String(usage.output_tokens), 658: cache_read_tokens: String(usage.cache_read_input_tokens), 659: cache_creation_tokens: String(usage.cache_creation_input_tokens), 660: cost_usd: String(costUSD), 661: duration_ms: String(durationMs), 662: speed: fastMode ? 'fast' : 'normal', 663: }) 664: let modelOutput: string | undefined 665: let thinkingOutput: string | undefined 666: let hasToolCall: boolean | undefined 667: if (isBetaTracingEnabled() && newMessages) { 668: modelOutput = 669: newMessages 670: .flatMap(m => 671: m.message.content 672: .filter(c => c.type === 'text') 673: .map(c => (c as { type: 'text'; text: string }).text), 674: ) 675: .join('\n') || undefined 676: if (process.env.USER_TYPE === 'ant') { 677: thinkingOutput = 678: newMessages 679: .flatMap(m => 680: m.message.content 681: .filter(c => c.type === 'thinking') 682: .map(c => (c as { type: 'thinking'; thinking: string }).thinking), 683: ) 684: .join('\n') || undefined 685: } 686: hasToolCall = newMessages.some(m => 687: m.message.content.some(c => c.type === 'tool_use'), 688: ) 689: } 690: endLLMRequestSpan(llmSpan, { 691: success: true, 692: inputTokens: usage.input_tokens, 693: outputTokens: usage.output_tokens, 694: cacheReadTokens: usage.cache_read_input_tokens, 695: cacheCreationTokens: usage.cache_creation_input_tokens, 696: attempt, 697: modelOutput, 698: thinkingOutput, 699: hasToolCall, 700: ttftMs: ttftMs ?? undefined, 701: requestSetupMs, 702: attemptStartTimes, 703: }) 704: const teleportInfo = getTeleportedSessionInfo() 705: if (teleportInfo?.isTeleported && !teleportInfo.hasLoggedFirstMessage) { 706: logEvent('tengu_teleport_first_message_success', { 707: session_id: 708: teleportInfo.sessionId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 709: }) 710: markFirstTeleportMessageLogged() 711: } 712: }

File: src/services/api/metricsOptOut.ts

typescript 1: import axios from 'axios' 2: import { hasProfileScope, isClaudeAISubscriber } from '../../utils/auth.js' 3: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 4: import { logForDebugging } from '../../utils/debug.js' 5: import { errorMessage } from '../../utils/errors.js' 6: import { getAuthHeaders, withOAuth401Retry } from '../../utils/http.js' 7: import { logError } from '../../utils/log.js' 8: import { memoizeWithTTLAsync } from '../../utils/memoize.js' 9: import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 10: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 11: type MetricsEnabledResponse = { 12: metrics_logging_enabled: boolean 13: } 14: type MetricsStatus = { 15: enabled: boolean 16: hasError: boolean 17: } 18: const CACHE_TTL_MS = 60 * 60 * 1000 19: const DISK_CACHE_TTL_MS = 24 * 60 * 60 * 1000 20: async function _fetchMetricsEnabled(): Promise<MetricsEnabledResponse> { 21: const authResult = getAuthHeaders() 22: if (authResult.error) { 23: throw new Error(`Auth error: ${authResult.error}`) 24: } 25: const headers = { 26: 'Content-Type': 'application/json', 27: 'User-Agent': getClaudeCodeUserAgent(), 28: ...authResult.headers, 29: } 30: const endpoint = `https://api.anthropic.com/api/claude_code/organizations/metrics_enabled` 31: const response = await axios.get<MetricsEnabledResponse>(endpoint, { 32: headers, 33: timeout: 5000, 34: }) 35: return response.data 36: } 37: async function _checkMetricsEnabledAPI(): Promise<MetricsStatus> { 38: if (isEssentialTrafficOnly()) { 39: return { enabled: false, hasError: false } 40: } 41: try { 42: const data = await withOAuth401Retry(_fetchMetricsEnabled, { 43: also403Revoked: true, 44: }) 45: logForDebugging( 46: `Metrics opt-out API response: enabled=${data.metrics_logging_enabled}`, 47: ) 48: return { 49: enabled: data.metrics_logging_enabled, 50: hasError: false, 51: } 52: } catch (error) { 53: logForDebugging( 54: `Failed to check metrics opt-out status: ${errorMessage(error)}`, 55: ) 56: logError(error) 57: return { enabled: false, hasError: true } 58: } 59: } 60: const memoizedCheckMetrics = memoizeWithTTLAsync( 61: _checkMetricsEnabledAPI, 62: CACHE_TTL_MS, 63: ) 64: async function refreshMetricsStatus(): Promise<MetricsStatus> { 65: const result = await memoizedCheckMetrics() 66: if (result.hasError) { 67: return result 68: } 69: const cached = getGlobalConfig().metricsStatusCache 70: const unchanged = cached !== undefined && cached.enabled === result.enabled 71: if (unchanged && Date.now() - cached.timestamp < DISK_CACHE_TTL_MS) { 72: return result 73: } 74: saveGlobalConfig(current => ({ 75: ...current, 76: metricsStatusCache: { 77: enabled: result.enabled, 78: timestamp: Date.now(), 79: }, 80: })) 81: return result 82: } 83: export async function checkMetricsEnabled(): Promise<MetricsStatus> { 84: if (isClaudeAISubscriber() && !hasProfileScope()) { 85: return { enabled: false, hasError: false } 86: } 87: const cached = getGlobalConfig().metricsStatusCache 88: if (cached) { 89: if (Date.now() - cached.timestamp > DISK_CACHE_TTL_MS) { 90: void refreshMetricsStatus().catch(logError) 91: } 92: return { 93: enabled: cached.enabled, 94: hasError: false, 95: } 96: } 97: return refreshMetricsStatus() 98: } 99: export const _clearMetricsEnabledCacheForTesting = (): void => { 100: memoizedCheckMetrics.cache.clear() 101: }

File: src/services/api/overageCreditGrant.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { getOauthAccountInfo } from '../../utils/auth.js' 4: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 5: import { logError } from '../../utils/log.js' 6: import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 7: import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js' 8: export type OverageCreditGrantInfo = { 9: available: boolean 10: eligible: boolean 11: granted: boolean 12: amount_minor_units: number | null 13: currency: string | null 14: } 15: type CachedGrantEntry = { 16: info: OverageCreditGrantInfo 17: timestamp: number 18: } 19: const CACHE_TTL_MS = 60 * 60 * 1000 20: async function fetchOverageCreditGrant(): Promise<OverageCreditGrantInfo | null> { 21: try { 22: const { accessToken, orgUUID } = await prepareApiRequest() 23: const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/overage_credit_grant` 24: const response = await axios.get<OverageCreditGrantInfo>(url, { 25: headers: getOAuthHeaders(accessToken), 26: }) 27: return response.data 28: } catch (err) { 29: logError(err) 30: return null 31: } 32: } 33: export function getCachedOverageCreditGrant(): OverageCreditGrantInfo | null { 34: const orgId = getOauthAccountInfo()?.organizationUuid 35: if (!orgId) return null 36: const cached = getGlobalConfig().overageCreditGrantCache?.[orgId] 37: if (!cached) return null 38: if (Date.now() - cached.timestamp > CACHE_TTL_MS) return null 39: return cached.info 40: } 41: export function invalidateOverageCreditGrantCache(): void { 42: const orgId = getOauthAccountInfo()?.organizationUuid 43: if (!orgId) return 44: const cache = getGlobalConfig().overageCreditGrantCache 45: if (!cache || !(orgId in cache)) return 46: saveGlobalConfig(prev => { 47: const next = { ...prev.overageCreditGrantCache } 48: delete next[orgId] 49: return { ...prev, overageCreditGrantCache: next } 50: }) 51: } 52: export async function refreshOverageCreditGrantCache(): Promise<void> { 53: if (isEssentialTrafficOnly()) return 54: const orgId = getOauthAccountInfo()?.organizationUuid 55: if (!orgId) return 56: const info = await fetchOverageCreditGrant() 57: if (!info) return 58: saveGlobalConfig(prev => { 59: const prevCached = prev.overageCreditGrantCache?.[orgId] 60: const existing = prevCached?.info 61: const dataUnchanged = 62: existing && 63: existing.available === info.available && 64: existing.eligible === info.eligible && 65: existing.granted === info.granted && 66: existing.amount_minor_units === info.amount_minor_units && 67: existing.currency === info.currency 68: if ( 69: dataUnchanged && 70: prevCached && 71: Date.now() - prevCached.timestamp <= CACHE_TTL_MS 72: ) { 73: return prev 74: } 75: const entry: CachedGrantEntry = { 76: info: dataUnchanged ? existing : info, 77: timestamp: Date.now(), 78: } 79: return { 80: ...prev, 81: overageCreditGrantCache: { 82: ...prev.overageCreditGrantCache, 83: [orgId]: entry, 84: }, 85: } 86: }) 87: } 88: export function formatGrantAmount(info: OverageCreditGrantInfo): string | null { 89: if (info.amount_minor_units == null || !info.currency) return null 90: if (info.currency.toUpperCase() === 'USD') { 91: const dollars = info.amount_minor_units / 100 92: return Number.isInteger(dollars) ? `$${dollars}` : `$${dollars.toFixed(2)}` 93: } 94: return null 95: } 96: export type { CachedGrantEntry as OverageCreditGrantCacheEntry }

File: src/services/api/promptCacheBreakDetection.ts

typescript 1: import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2: import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 3: import { createPatch } from 'diff' 4: import { mkdir, writeFile } from 'fs/promises' 5: import { join } from 'path' 6: import type { AgentId } from 'src/types/ids.js' 7: import type { Message } from 'src/types/message.js' 8: import { logForDebugging } from 'src/utils/debug.js' 9: import { djb2Hash } from 'src/utils/hash.js' 10: import { logError } from 'src/utils/log.js' 11: import { getClaudeTempDir } from 'src/utils/permissions/filesystem.js' 12: import { jsonStringify } from 'src/utils/slowOperations.js' 13: import type { QuerySource } from '../../constants/querySource.js' 14: import { 15: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 16: logEvent, 17: } from '../analytics/index.js' 18: function getCacheBreakDiffPath(): string { 19: const chars = 'abcdefghijklmnopqrstuvwxyz0123456789' 20: let suffix = '' 21: for (let i = 0; i < 4; i++) { 22: suffix += chars[Math.floor(Math.random() * chars.length)] 23: } 24: return join(getClaudeTempDir(), `cache-break-${suffix}.diff`) 25: } 26: type PreviousState = { 27: systemHash: number 28: toolsHash: number 29: /** Hash of system blocks WITH cache_control intact. Catches scope/TTL flips 30: * (global↔org, 1h↔5m) that stripCacheControl erases from systemHash. */ 31: cacheControlHash: number 32: toolNames: string[] 33: /** Per-tool schema hash. Diffed to name which tool's description changed 34: * when toolSchemasChanged but added=removed=0 (77% of tool breaks per 35: * BQ 2026-03-22). AgentTool/SkillTool embed dynamic agent/command lists. */ 36: perToolHashes: Record<string, number> 37: systemCharCount: number 38: model: string 39: fastMode: boolean 40: globalCacheStrategy: string 41: betas: string[] 42: autoModeActive: boolean 43: isUsingOverage: boolean 44: cachedMCEnabled: boolean 45: effortValue: string 46: extraBodyHash: number 47: callCount: number 48: pendingChanges: PendingChanges | null 49: prevCacheReadTokens: number | null 50: cacheDeletionsPending: boolean 51: buildDiffableContent: () => string 52: } 53: type PendingChanges = { 54: systemPromptChanged: boolean 55: toolSchemasChanged: boolean 56: modelChanged: boolean 57: fastModeChanged: boolean 58: cacheControlChanged: boolean 59: globalCacheStrategyChanged: boolean 60: betasChanged: boolean 61: autoModeChanged: boolean 62: overageChanged: boolean 63: cachedMCChanged: boolean 64: effortChanged: boolean 65: extraBodyChanged: boolean 66: addedToolCount: number 67: removedToolCount: number 68: systemCharDelta: number 69: addedTools: string[] 70: removedTools: string[] 71: changedToolSchemas: string[] 72: previousModel: string 73: newModel: string 74: prevGlobalCacheStrategy: string 75: newGlobalCacheStrategy: string 76: addedBetas: string[] 77: removedBetas: string[] 78: prevEffortValue: string 79: newEffortValue: string 80: buildPrevDiffableContent: () => string 81: } 82: const previousStateBySource = new Map<string, PreviousState>() 83: const MAX_TRACKED_SOURCES = 10 84: const TRACKED_SOURCE_PREFIXES = [ 85: 'repl_main_thread', 86: 'sdk', 87: 'agent:custom', 88: 'agent:default', 89: 'agent:builtin', 90: ] 91: const MIN_CACHE_MISS_TOKENS = 2_000 92: const CACHE_TTL_5MIN_MS = 5 * 60 * 1000 93: export const CACHE_TTL_1HOUR_MS = 60 * 60 * 1000 94: function isExcludedModel(model: string): boolean { 95: return model.includes('haiku') 96: } 97: function getTrackingKey( 98: querySource: QuerySource, 99: agentId?: AgentId, 100: ): string | null { 101: if (querySource === 'compact') return 'repl_main_thread' 102: for (const prefix of TRACKED_SOURCE_PREFIXES) { 103: if (querySource.startsWith(prefix)) return agentId || querySource 104: } 105: return null 106: } 107: function stripCacheControl( 108: items: ReadonlyArray<Record<string, unknown>>, 109: ): unknown[] { 110: return items.map(item => { 111: if (!('cache_control' in item)) return item 112: const { cache_control: _, ...rest } = item 113: return rest 114: }) 115: } 116: function computeHash(data: unknown): number { 117: const str = jsonStringify(data) 118: if (typeof Bun !== 'undefined') { 119: const hash = Bun.hash(str) 120: return typeof hash === 'bigint' ? Number(hash & 0xffffffffn) : hash 121: } 122: return djb2Hash(str) 123: } 124: function sanitizeToolName(name: string): string { 125: return name.startsWith('mcp__') ? 'mcp' : name 126: } 127: function computePerToolHashes( 128: strippedTools: ReadonlyArray<unknown>, 129: names: string[], 130: ): Record<string, number> { 131: const hashes: Record<string, number> = {} 132: for (let i = 0; i < strippedTools.length; i++) { 133: hashes[names[i] ?? `__idx_${i}`] = computeHash(strippedTools[i]) 134: } 135: return hashes 136: } 137: function getSystemCharCount(system: TextBlockParam[]): number { 138: let total = 0 139: for (const block of system) { 140: total += block.text.length 141: } 142: return total 143: } 144: function buildDiffableContent( 145: system: TextBlockParam[], 146: tools: BetaToolUnion[], 147: model: string, 148: ): string { 149: const systemText = system.map(b => b.text).join('\n\n') 150: const toolDetails = tools 151: .map(t => { 152: if (!('name' in t)) return 'unknown' 153: const desc = 'description' in t ? t.description : '' 154: const schema = 'input_schema' in t ? jsonStringify(t.input_schema) : '' 155: return `${t.name}\n description: ${desc}\n input_schema: ${schema}` 156: }) 157: .sort() 158: .join('\n\n') 159: return `Model: ${model}\n\n=== System Prompt ===\n\n${systemText}\n\n=== Tools (${tools.length}) ===\n\n${toolDetails}\n` 160: } 161: export type PromptStateSnapshot = { 162: system: TextBlockParam[] 163: toolSchemas: BetaToolUnion[] 164: querySource: QuerySource 165: model: string 166: agentId?: AgentId 167: fastMode?: boolean 168: globalCacheStrategy?: string 169: betas?: readonly string[] 170: autoModeActive?: boolean 171: isUsingOverage?: boolean 172: cachedMCEnabled?: boolean 173: effortValue?: string | number 174: extraBodyParams?: unknown 175: } 176: export function recordPromptState(snapshot: PromptStateSnapshot): void { 177: try { 178: const { 179: system, 180: toolSchemas, 181: querySource, 182: model, 183: agentId, 184: fastMode, 185: globalCacheStrategy = '', 186: betas = [], 187: autoModeActive = false, 188: isUsingOverage = false, 189: cachedMCEnabled = false, 190: effortValue, 191: extraBodyParams, 192: } = snapshot 193: const key = getTrackingKey(querySource, agentId) 194: if (!key) return 195: const strippedSystem = stripCacheControl( 196: system as unknown as ReadonlyArray<Record<string, unknown>>, 197: ) 198: const strippedTools = stripCacheControl( 199: toolSchemas as unknown as ReadonlyArray<Record<string, unknown>>, 200: ) 201: const systemHash = computeHash(strippedSystem) 202: const toolsHash = computeHash(strippedTools) 203: // Hash the full system array INCLUDING cache_control — this catches 204: // scope flips (global↔org/none) and TTL flips (1h↔5m) that the stripped 205: // hash can't see because the text content is identical. 206: const cacheControlHash = computeHash( 207: system.map(b => ('cache_control' in b ? b.cache_control : null)), 208: ) 209: const toolNames = toolSchemas.map(t => ('name' in t ? t.name : 'unknown')) 210: const computeToolHashes = () => 211: computePerToolHashes(strippedTools, toolNames) 212: const systemCharCount = getSystemCharCount(system) 213: const lazyDiffableContent = () => 214: buildDiffableContent(system, toolSchemas, model) 215: const isFastMode = fastMode ?? false 216: const sortedBetas = [...betas].sort() 217: const effortStr = effortValue === undefined ? '' : String(effortValue) 218: const extraBodyHash = 219: extraBodyParams === undefined ? 0 : computeHash(extraBodyParams) 220: const prev = previousStateBySource.get(key) 221: if (!prev) { 222: // Evict oldest entries if map is at capacity 223: while (previousStateBySource.size >= MAX_TRACKED_SOURCES) { 224: const oldest = previousStateBySource.keys().next().value 225: if (oldest !== undefined) previousStateBySource.delete(oldest) 226: } 227: previousStateBySource.set(key, { 228: systemHash, 229: toolsHash, 230: cacheControlHash, 231: toolNames, 232: systemCharCount, 233: model, 234: fastMode: isFastMode, 235: globalCacheStrategy, 236: betas: sortedBetas, 237: autoModeActive, 238: isUsingOverage, 239: cachedMCEnabled, 240: effortValue: effortStr, 241: extraBodyHash, 242: callCount: 1, 243: pendingChanges: null, 244: prevCacheReadTokens: null, 245: cacheDeletionsPending: false, 246: buildDiffableContent: lazyDiffableContent, 247: perToolHashes: computeToolHashes(), 248: }) 249: return 250: } 251: prev.callCount++ 252: const systemPromptChanged = systemHash !== prev.systemHash 253: const toolSchemasChanged = toolsHash !== prev.toolsHash 254: const modelChanged = model !== prev.model 255: const fastModeChanged = isFastMode !== prev.fastMode 256: const cacheControlChanged = cacheControlHash !== prev.cacheControlHash 257: const globalCacheStrategyChanged = 258: globalCacheStrategy !== prev.globalCacheStrategy 259: const betasChanged = 260: sortedBetas.length !== prev.betas.length || 261: sortedBetas.some((b, i) => b !== prev.betas[i]) 262: const autoModeChanged = autoModeActive !== prev.autoModeActive 263: const overageChanged = isUsingOverage !== prev.isUsingOverage 264: const cachedMCChanged = cachedMCEnabled !== prev.cachedMCEnabled 265: const effortChanged = effortStr !== prev.effortValue 266: const extraBodyChanged = extraBodyHash !== prev.extraBodyHash 267: if ( 268: systemPromptChanged || 269: toolSchemasChanged || 270: modelChanged || 271: fastModeChanged || 272: cacheControlChanged || 273: globalCacheStrategyChanged || 274: betasChanged || 275: autoModeChanged || 276: overageChanged || 277: cachedMCChanged || 278: effortChanged || 279: extraBodyChanged 280: ) { 281: const prevToolSet = new Set(prev.toolNames) 282: const newToolSet = new Set(toolNames) 283: const prevBetaSet = new Set(prev.betas) 284: const newBetaSet = new Set(sortedBetas) 285: const addedTools = toolNames.filter(n => !prevToolSet.has(n)) 286: const removedTools = prev.toolNames.filter(n => !newToolSet.has(n)) 287: const changedToolSchemas: string[] = [] 288: if (toolSchemasChanged) { 289: const newHashes = computeToolHashes() 290: for (const name of toolNames) { 291: if (!prevToolSet.has(name)) continue 292: if (newHashes[name] !== prev.perToolHashes[name]) { 293: changedToolSchemas.push(name) 294: } 295: } 296: prev.perToolHashes = newHashes 297: } 298: prev.pendingChanges = { 299: systemPromptChanged, 300: toolSchemasChanged, 301: modelChanged, 302: fastModeChanged, 303: cacheControlChanged, 304: globalCacheStrategyChanged, 305: betasChanged, 306: autoModeChanged, 307: overageChanged, 308: cachedMCChanged, 309: effortChanged, 310: extraBodyChanged, 311: addedToolCount: addedTools.length, 312: removedToolCount: removedTools.length, 313: addedTools, 314: removedTools, 315: changedToolSchemas, 316: systemCharDelta: systemCharCount - prev.systemCharCount, 317: previousModel: prev.model, 318: newModel: model, 319: prevGlobalCacheStrategy: prev.globalCacheStrategy, 320: newGlobalCacheStrategy: globalCacheStrategy, 321: addedBetas: sortedBetas.filter(b => !prevBetaSet.has(b)), 322: removedBetas: prev.betas.filter(b => !newBetaSet.has(b)), 323: prevEffortValue: prev.effortValue, 324: newEffortValue: effortStr, 325: buildPrevDiffableContent: prev.buildDiffableContent, 326: } 327: } else { 328: prev.pendingChanges = null 329: } 330: prev.systemHash = systemHash 331: prev.toolsHash = toolsHash 332: prev.cacheControlHash = cacheControlHash 333: prev.toolNames = toolNames 334: prev.systemCharCount = systemCharCount 335: prev.model = model 336: prev.fastMode = isFastMode 337: prev.globalCacheStrategy = globalCacheStrategy 338: prev.betas = sortedBetas 339: prev.autoModeActive = autoModeActive 340: prev.isUsingOverage = isUsingOverage 341: prev.cachedMCEnabled = cachedMCEnabled 342: prev.effortValue = effortStr 343: prev.extraBodyHash = extraBodyHash 344: prev.buildDiffableContent = lazyDiffableContent 345: } catch (e: unknown) { 346: logError(e) 347: } 348: } 349: /** 350: * Phase 2 (post-call): Check the API response's cache tokens to determine 351: * if a cache break actually occurred. If it did, use the pending changes 352: * from phase 1 to explain why. 353: */ 354: export async function checkResponseForCacheBreak( 355: querySource: QuerySource, 356: cacheReadTokens: number, 357: cacheCreationTokens: number, 358: messages: Message[], 359: agentId?: AgentId, 360: requestId?: string | null, 361: ): Promise<void> { 362: try { 363: const key = getTrackingKey(querySource, agentId) 364: if (!key) return 365: const state = previousStateBySource.get(key) 366: if (!state) return 367: if (isExcludedModel(state.model)) return 368: const prevCacheRead = state.prevCacheReadTokens 369: state.prevCacheReadTokens = cacheReadTokens 370: const lastAssistantMessage = messages.findLast(m => m.type === 'assistant') 371: const timeSinceLastAssistantMsg = lastAssistantMessage 372: ? Date.now() - new Date(lastAssistantMessage.timestamp).getTime() 373: : null 374: if (prevCacheRead === null) return 375: const changes = state.pendingChanges 376: if (state.cacheDeletionsPending) { 377: state.cacheDeletionsPending = false 378: logForDebugging( 379: `[PROMPT CACHE] cache deletion applied, cache read: ${prevCacheRead} → ${cacheReadTokens} (expected drop)`, 380: ) 381: state.pendingChanges = null 382: return 383: } 384: const tokenDrop = prevCacheRead - cacheReadTokens 385: if ( 386: cacheReadTokens >= prevCacheRead * 0.95 || 387: tokenDrop < MIN_CACHE_MISS_TOKENS 388: ) { 389: state.pendingChanges = null 390: return 391: } 392: const parts: string[] = [] 393: if (changes) { 394: if (changes.modelChanged) { 395: parts.push( 396: `model changed (${changes.previousModel} → ${changes.newModel})`, 397: ) 398: } 399: if (changes.systemPromptChanged) { 400: const charDelta = changes.systemCharDelta 401: const charInfo = 402: charDelta === 0 403: ? '' 404: : charDelta > 0 405: ? ` (+${charDelta} chars)` 406: : ` (${charDelta} chars)` 407: parts.push(`system prompt changed${charInfo}`) 408: } 409: if (changes.toolSchemasChanged) { 410: const toolDiff = 411: changes.addedToolCount > 0 || changes.removedToolCount > 0 412: ? ` (+${changes.addedToolCount}/-${changes.removedToolCount} tools)` 413: : ' (tool prompt/schema changed, same tool set)' 414: parts.push(`tools changed${toolDiff}`) 415: } 416: if (changes.fastModeChanged) { 417: parts.push('fast mode toggled') 418: } 419: if (changes.globalCacheStrategyChanged) { 420: parts.push( 421: `global cache strategy changed (${changes.prevGlobalCacheStrategy || 'none'} → ${changes.newGlobalCacheStrategy || 'none'})`, 422: ) 423: } 424: if ( 425: changes.cacheControlChanged && 426: !changes.globalCacheStrategyChanged && 427: !changes.systemPromptChanged 428: ) { 429: parts.push('cache_control changed (scope or TTL)') 430: } 431: if (changes.betasChanged) { 432: const added = changes.addedBetas.length 433: ? `+${changes.addedBetas.join(',')}` 434: : '' 435: const removed = changes.removedBetas.length 436: ? `-${changes.removedBetas.join(',')}` 437: : '' 438: const diff = [added, removed].filter(Boolean).join(' ') 439: parts.push(`betas changed${diff ? ` (${diff})` : ''}`) 440: } 441: if (changes.autoModeChanged) { 442: parts.push('auto mode toggled') 443: } 444: if (changes.overageChanged) { 445: parts.push('overage state changed (TTL latched, no flip)') 446: } 447: if (changes.cachedMCChanged) { 448: parts.push('cached microcompact toggled') 449: } 450: if (changes.effortChanged) { 451: parts.push( 452: `effort changed (${changes.prevEffortValue || 'default'} → ${changes.newEffortValue || 'default'})`, 453: ) 454: } 455: if (changes.extraBodyChanged) { 456: parts.push('extra body params changed') 457: } 458: } 459: const lastAssistantMsgOver5minAgo = 460: timeSinceLastAssistantMsg !== null && 461: timeSinceLastAssistantMsg > CACHE_TTL_5MIN_MS 462: const lastAssistantMsgOver1hAgo = 463: timeSinceLastAssistantMsg !== null && 464: timeSinceLastAssistantMsg > CACHE_TTL_1HOUR_MS 465: let reason: string 466: if (parts.length > 0) { 467: reason = parts.join(', ') 468: } else if (lastAssistantMsgOver1hAgo) { 469: reason = 'possible 1h TTL expiry (prompt unchanged)' 470: } else if (lastAssistantMsgOver5minAgo) { 471: reason = 'possible 5min TTL expiry (prompt unchanged)' 472: } else if (timeSinceLastAssistantMsg !== null) { 473: reason = 'likely server-side (prompt unchanged, <5min gap)' 474: } else { 475: reason = 'unknown cause' 476: } 477: logEvent('tengu_prompt_cache_break', { 478: systemPromptChanged: changes?.systemPromptChanged ?? false, 479: toolSchemasChanged: changes?.toolSchemasChanged ?? false, 480: modelChanged: changes?.modelChanged ?? false, 481: fastModeChanged: changes?.fastModeChanged ?? false, 482: cacheControlChanged: changes?.cacheControlChanged ?? false, 483: globalCacheStrategyChanged: changes?.globalCacheStrategyChanged ?? false, 484: betasChanged: changes?.betasChanged ?? false, 485: autoModeChanged: changes?.autoModeChanged ?? false, 486: overageChanged: changes?.overageChanged ?? false, 487: cachedMCChanged: changes?.cachedMCChanged ?? false, 488: effortChanged: changes?.effortChanged ?? false, 489: extraBodyChanged: changes?.extraBodyChanged ?? false, 490: addedToolCount: changes?.addedToolCount ?? 0, 491: removedToolCount: changes?.removedToolCount ?? 0, 492: systemCharDelta: changes?.systemCharDelta ?? 0, 493: addedTools: (changes?.addedTools ?? []) 494: .map(sanitizeToolName) 495: .join( 496: ',', 497: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 498: removedTools: (changes?.removedTools ?? []) 499: .map(sanitizeToolName) 500: .join( 501: ',', 502: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 503: changedToolSchemas: (changes?.changedToolSchemas ?? []) 504: .map(sanitizeToolName) 505: .join( 506: ',', 507: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 508: addedBetas: (changes?.addedBetas ?? []).join( 509: ',', 510: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 511: removedBetas: (changes?.removedBetas ?? []).join( 512: ',', 513: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 514: prevGlobalCacheStrategy: (changes?.prevGlobalCacheStrategy ?? 515: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 516: newGlobalCacheStrategy: (changes?.newGlobalCacheStrategy ?? 517: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 518: callNumber: state.callCount, 519: prevCacheReadTokens: prevCacheRead, 520: cacheReadTokens, 521: cacheCreationTokens, 522: timeSinceLastAssistantMsg: timeSinceLastAssistantMsg ?? -1, 523: lastAssistantMsgOver5minAgo, 524: lastAssistantMsgOver1hAgo, 525: requestId: (requestId ?? 526: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 527: }) 528: // Write diff file for ant debugging via --debug. The path is included in 529: // the summary log so ants can find it (DevBar UI removed — event data 530: // flows reliably to BQ for analytics). 531: let diffPath: string | undefined 532: if (changes?.buildPrevDiffableContent) { 533: diffPath = await writeCacheBreakDiff( 534: changes.buildPrevDiffableContent(), 535: state.buildDiffableContent(), 536: ) 537: } 538: const diffSuffix = diffPath ? `, diff: ${diffPath}` : '' 539: const summary = `[PROMPT CACHE BREAK] ${reason} [source=${querySource}, call #${state.callCount}, cache read: ${prevCacheRead} → ${cacheReadTokens}, creation: ${cacheCreationTokens}${diffSuffix}]` 540: logForDebugging(summary, { level: 'warn' }) 541: state.pendingChanges = null 542: } catch (e: unknown) { 543: logError(e) 544: } 545: } 546: export function notifyCacheDeletion( 547: querySource: QuerySource, 548: agentId?: AgentId, 549: ): void { 550: const key = getTrackingKey(querySource, agentId) 551: const state = key ? previousStateBySource.get(key) : undefined 552: if (state) { 553: state.cacheDeletionsPending = true 554: } 555: } 556: export function notifyCompaction( 557: querySource: QuerySource, 558: agentId?: AgentId, 559: ): void { 560: const key = getTrackingKey(querySource, agentId) 561: const state = key ? previousStateBySource.get(key) : undefined 562: if (state) { 563: state.prevCacheReadTokens = null 564: } 565: } 566: export function cleanupAgentTracking(agentId: AgentId): void { 567: previousStateBySource.delete(agentId) 568: } 569: export function resetPromptCacheBreakDetection(): void { 570: previousStateBySource.clear() 571: } 572: async function writeCacheBreakDiff( 573: prevContent: string, 574: newContent: string, 575: ): Promise<string | undefined> { 576: try { 577: const diffPath = getCacheBreakDiffPath() 578: await mkdir(getClaudeTempDir(), { recursive: true }) 579: const patch = createPatch( 580: 'prompt-state', 581: prevContent, 582: newContent, 583: 'before', 584: 'after', 585: ) 586: await writeFile(diffPath, patch) 587: return diffPath 588: } catch { 589: return undefined 590: } 591: }

File: src/services/api/referral.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { 4: getOauthAccountInfo, 5: getSubscriptionType, 6: isClaudeAISubscriber, 7: } from '../../utils/auth.js' 8: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 9: import { logForDebugging } from '../../utils/debug.js' 10: import { logError } from '../../utils/log.js' 11: import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 12: import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js' 13: import type { 14: ReferralCampaign, 15: ReferralEligibilityResponse, 16: ReferralRedemptionsResponse, 17: ReferrerRewardInfo, 18: } from '../oauth/types.js' 19: const CACHE_EXPIRATION_MS = 24 * 60 * 60 * 1000 20: let fetchInProgress: Promise<ReferralEligibilityResponse | null> | null = null 21: export async function fetchReferralEligibility( 22: campaign: ReferralCampaign = 'claude_code_guest_pass', 23: ): Promise<ReferralEligibilityResponse> { 24: const { accessToken, orgUUID } = await prepareApiRequest() 25: const headers = { 26: ...getOAuthHeaders(accessToken), 27: 'x-organization-uuid': orgUUID, 28: } 29: const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/referral/eligibility` 30: const response = await axios.get(url, { 31: headers, 32: params: { campaign }, 33: timeout: 5000, 34: }) 35: return response.data 36: } 37: export async function fetchReferralRedemptions( 38: campaign: string = 'claude_code_guest_pass', 39: ): Promise<ReferralRedemptionsResponse> { 40: const { accessToken, orgUUID } = await prepareApiRequest() 41: const headers = { 42: ...getOAuthHeaders(accessToken), 43: 'x-organization-uuid': orgUUID, 44: } 45: const url = `${getOauthConfig().BASE_API_URL}/api/oauth/organizations/${orgUUID}/referral/redemptions` 46: const response = await axios.get<ReferralRedemptionsResponse>(url, { 47: headers, 48: params: { campaign }, 49: timeout: 10000, 50: }) 51: return response.data 52: } 53: function shouldCheckForPasses(): boolean { 54: return !!( 55: getOauthAccountInfo()?.organizationUuid && 56: isClaudeAISubscriber() && 57: getSubscriptionType() === 'max' 58: ) 59: } 60: export function checkCachedPassesEligibility(): { 61: eligible: boolean 62: needsRefresh: boolean 63: hasCache: boolean 64: } { 65: if (!shouldCheckForPasses()) { 66: return { 67: eligible: false, 68: needsRefresh: false, 69: hasCache: false, 70: } 71: } 72: const orgId = getOauthAccountInfo()?.organizationUuid 73: if (!orgId) { 74: return { 75: eligible: false, 76: needsRefresh: false, 77: hasCache: false, 78: } 79: } 80: const config = getGlobalConfig() 81: const cachedEntry = config.passesEligibilityCache?.[orgId] 82: if (!cachedEntry) { 83: return { 84: eligible: false, 85: needsRefresh: true, 86: hasCache: false, 87: } 88: } 89: const { eligible, timestamp } = cachedEntry 90: const now = Date.now() 91: const needsRefresh = now - timestamp > CACHE_EXPIRATION_MS 92: return { 93: eligible, 94: needsRefresh, 95: hasCache: true, 96: } 97: } 98: const CURRENCY_SYMBOLS: Record<string, string> = { 99: USD: '$', 100: EUR: '€', 101: GBP: '£', 102: BRL: 'R$', 103: CAD: 'CA$', 104: AUD: 'A$', 105: NZD: 'NZ$', 106: SGD: 'S$', 107: } 108: export function formatCreditAmount(reward: ReferrerRewardInfo): string { 109: const symbol = CURRENCY_SYMBOLS[reward.currency] ?? `${reward.currency} ` 110: const amount = reward.amount_minor_units / 100 111: const formatted = amount % 1 === 0 ? amount.toString() : amount.toFixed(2) 112: return `${symbol}${formatted}` 113: } 114: export function getCachedReferrerReward(): ReferrerRewardInfo | null { 115: const orgId = getOauthAccountInfo()?.organizationUuid 116: if (!orgId) return null 117: const config = getGlobalConfig() 118: const cachedEntry = config.passesEligibilityCache?.[orgId] 119: return cachedEntry?.referrer_reward ?? null 120: } 121: export function getCachedRemainingPasses(): number | null { 122: const orgId = getOauthAccountInfo()?.organizationUuid 123: if (!orgId) return null 124: const config = getGlobalConfig() 125: const cachedEntry = config.passesEligibilityCache?.[orgId] 126: return cachedEntry?.remaining_passes ?? null 127: } 128: export async function fetchAndStorePassesEligibility(): Promise<ReferralEligibilityResponse | null> { 129: if (fetchInProgress) { 130: logForDebugging('Passes: Reusing in-flight eligibility fetch') 131: return fetchInProgress 132: } 133: const orgId = getOauthAccountInfo()?.organizationUuid 134: if (!orgId) { 135: return null 136: } 137: fetchInProgress = (async () => { 138: try { 139: const response = await fetchReferralEligibility() 140: const cacheEntry = { 141: ...response, 142: timestamp: Date.now(), 143: } 144: saveGlobalConfig(current => ({ 145: ...current, 146: passesEligibilityCache: { 147: ...current.passesEligibilityCache, 148: [orgId]: cacheEntry, 149: }, 150: })) 151: logForDebugging( 152: `Passes eligibility cached for org ${orgId}: ${response.eligible}`, 153: ) 154: return response 155: } catch (error) { 156: logForDebugging('Failed to fetch and cache passes eligibility') 157: logError(error as Error) 158: return null 159: } finally { 160: fetchInProgress = null 161: } 162: })() 163: return fetchInProgress 164: } 165: export async function getCachedOrFetchPassesEligibility(): Promise<ReferralEligibilityResponse | null> { 166: if (!shouldCheckForPasses()) { 167: return null 168: } 169: const orgId = getOauthAccountInfo()?.organizationUuid 170: if (!orgId) { 171: return null 172: } 173: const config = getGlobalConfig() 174: const cachedEntry = config.passesEligibilityCache?.[orgId] 175: const now = Date.now() 176: if (!cachedEntry) { 177: logForDebugging( 178: 'Passes: No cache, fetching eligibility in background (command unavailable this session)', 179: ) 180: void fetchAndStorePassesEligibility() 181: return null 182: } 183: if (now - cachedEntry.timestamp > CACHE_EXPIRATION_MS) { 184: logForDebugging( 185: 'Passes: Cache stale, returning cached data and refreshing in background', 186: ) 187: void fetchAndStorePassesEligibility() 188: const { timestamp, ...response } = cachedEntry 189: return response as ReferralEligibilityResponse 190: } 191: logForDebugging('Passes: Using fresh cached eligibility data') 192: const { timestamp, ...response } = cachedEntry 193: return response as ReferralEligibilityResponse 194: } 195: export async function prefetchPassesEligibility(): Promise<void> { 196: if (isEssentialTrafficOnly()) { 197: return 198: } 199: void getCachedOrFetchPassesEligibility() 200: }

File: src/services/api/sessionIngress.ts

typescript 1: import axios, { type AxiosError } from 'axios' 2: import type { UUID } from 'crypto' 3: import { getOauthConfig } from '../../constants/oauth.js' 4: import type { Entry, TranscriptMessage } from '../../types/logs.js' 5: import { logForDebugging } from '../../utils/debug.js' 6: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 7: import { isEnvTruthy } from '../../utils/envUtils.js' 8: import { logError } from '../../utils/log.js' 9: import { sequential } from '../../utils/sequential.js' 10: import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' 11: import { sleep } from '../../utils/sleep.js' 12: import { jsonStringify } from '../../utils/slowOperations.js' 13: import { getOAuthHeaders } from '../../utils/teleport/api.js' 14: interface SessionIngressError { 15: error?: { 16: message?: string 17: type?: string 18: } 19: } 20: const lastUuidMap: Map<string, UUID> = new Map() 21: const MAX_RETRIES = 10 22: const BASE_DELAY_MS = 500 23: const sequentialAppendBySession: Map< 24: string, 25: ( 26: entry: TranscriptMessage, 27: url: string, 28: headers: Record<string, string>, 29: ) => Promise<boolean> 30: > = new Map() 31: function getOrCreateSequentialAppend(sessionId: string) { 32: let sequentialAppend = sequentialAppendBySession.get(sessionId) 33: if (!sequentialAppend) { 34: sequentialAppend = sequential( 35: async ( 36: entry: TranscriptMessage, 37: url: string, 38: headers: Record<string, string>, 39: ) => await appendSessionLogImpl(sessionId, entry, url, headers), 40: ) 41: sequentialAppendBySession.set(sessionId, sequentialAppend) 42: } 43: return sequentialAppend 44: } 45: async function appendSessionLogImpl( 46: sessionId: string, 47: entry: TranscriptMessage, 48: url: string, 49: headers: Record<string, string>, 50: ): Promise<boolean> { 51: for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { 52: try { 53: const lastUuid = lastUuidMap.get(sessionId) 54: const requestHeaders = { ...headers } 55: if (lastUuid) { 56: requestHeaders['Last-Uuid'] = lastUuid 57: } 58: const response = await axios.put(url, entry, { 59: headers: requestHeaders, 60: validateStatus: status => status < 500, 61: }) 62: if (response.status === 200 || response.status === 201) { 63: lastUuidMap.set(sessionId, entry.uuid) 64: logForDebugging( 65: `Successfully persisted session log entry for session ${sessionId}`, 66: ) 67: return true 68: } 69: if (response.status === 409) { 70: const serverLastUuid = response.headers['x-last-uuid'] 71: if (serverLastUuid === entry.uuid) { 72: lastUuidMap.set(sessionId, entry.uuid) 73: logForDebugging( 74: `Session entry ${entry.uuid} already present on server, recovering from stale state`, 75: ) 76: logForDiagnosticsNoPII('info', 'session_persist_recovered_from_409') 77: return true 78: } 79: if (serverLastUuid) { 80: lastUuidMap.set(sessionId, serverLastUuid as UUID) 81: logForDebugging( 82: `Session 409: adopting server lastUuid=${serverLastUuid} from header, retrying entry ${entry.uuid}`, 83: ) 84: } else { 85: const logs = await fetchSessionLogsFromUrl(sessionId, url, headers) 86: const adoptedUuid = findLastUuid(logs) 87: if (adoptedUuid) { 88: lastUuidMap.set(sessionId, adoptedUuid) 89: logForDebugging( 90: `Session 409: re-fetched ${logs!.length} entries, adopting lastUuid=${adoptedUuid}, retrying entry ${entry.uuid}`, 91: ) 92: } else { 93: const errorData = response.data as SessionIngressError 94: const errorMessage = 95: errorData.error?.message || 'Concurrent modification detected' 96: logError( 97: new Error( 98: `Session persistence conflict: UUID mismatch for session ${sessionId}, entry ${entry.uuid}. ${errorMessage}`, 99: ), 100: ) 101: logForDiagnosticsNoPII( 102: 'error', 103: 'session_persist_fail_concurrent_modification', 104: ) 105: return false 106: } 107: } 108: logForDiagnosticsNoPII('info', 'session_persist_409_adopt_server_uuid') 109: continue 110: } 111: if (response.status === 401) { 112: logForDebugging('Session token expired or invalid') 113: logForDiagnosticsNoPII('error', 'session_persist_fail_bad_token') 114: return false 115: } 116: logForDebugging( 117: `Failed to persist session log: ${response.status} ${response.statusText}`, 118: ) 119: logForDiagnosticsNoPII('error', 'session_persist_fail_status', { 120: status: response.status, 121: attempt, 122: }) 123: } catch (error) { 124: const axiosError = error as AxiosError<SessionIngressError> 125: logError(new Error(`Error persisting session log: ${axiosError.message}`)) 126: logForDiagnosticsNoPII('error', 'session_persist_fail_status', { 127: status: axiosError.status, 128: attempt, 129: }) 130: } 131: if (attempt === MAX_RETRIES) { 132: logForDebugging(`Remote persistence failed after ${MAX_RETRIES} attempts`) 133: logForDiagnosticsNoPII( 134: 'error', 135: 'session_persist_error_retries_exhausted', 136: { attempt }, 137: ) 138: return false 139: } 140: const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, attempt - 1), 8000) 141: logForDebugging( 142: `Remote persistence attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delayMs}ms…`, 143: ) 144: await sleep(delayMs) 145: } 146: return false 147: } 148: export async function appendSessionLog( 149: sessionId: string, 150: entry: TranscriptMessage, 151: url: string, 152: ): Promise<boolean> { 153: const sessionToken = getSessionIngressAuthToken() 154: if (!sessionToken) { 155: logForDebugging('No session token available for session persistence') 156: logForDiagnosticsNoPII('error', 'session_persist_fail_jwt_no_token') 157: return false 158: } 159: const headers: Record<string, string> = { 160: Authorization: `Bearer ${sessionToken}`, 161: 'Content-Type': 'application/json', 162: } 163: const sequentialAppend = getOrCreateSequentialAppend(sessionId) 164: return sequentialAppend(entry, url, headers) 165: } 166: export async function getSessionLogs( 167: sessionId: string, 168: url: string, 169: ): Promise<Entry[] | null> { 170: const sessionToken = getSessionIngressAuthToken() 171: if (!sessionToken) { 172: logForDebugging('No session token available for fetching session logs') 173: logForDiagnosticsNoPII('error', 'session_get_fail_no_token') 174: return null 175: } 176: const headers = { Authorization: `Bearer ${sessionToken}` } 177: const logs = await fetchSessionLogsFromUrl(sessionId, url, headers) 178: if (logs && logs.length > 0) { 179: const lastEntry = logs.at(-1) 180: if (lastEntry && 'uuid' in lastEntry && lastEntry.uuid) { 181: lastUuidMap.set(sessionId, lastEntry.uuid) 182: } 183: } 184: return logs 185: } 186: export async function getSessionLogsViaOAuth( 187: sessionId: string, 188: accessToken: string, 189: orgUUID: string, 190: ): Promise<Entry[] | null> { 191: const url = `${getOauthConfig().BASE_API_URL}/v1/session_ingress/session/${sessionId}` 192: logForDebugging(`[session-ingress] Fetching session logs from: ${url}`) 193: const headers = { 194: ...getOAuthHeaders(accessToken), 195: 'x-organization-uuid': orgUUID, 196: } 197: const result = await fetchSessionLogsFromUrl(sessionId, url, headers) 198: return result 199: } 200: type TeleportEventsResponse = { 201: data: Array<{ 202: event_id: string 203: event_type: string 204: is_compaction: boolean 205: payload: Entry | null 206: created_at: string 207: }> 208: next_cursor?: string 209: } 210: export async function getTeleportEvents( 211: sessionId: string, 212: accessToken: string, 213: orgUUID: string, 214: ): Promise<Entry[] | null> { 215: const baseUrl = `${getOauthConfig().BASE_API_URL}/v1/code/sessions/${sessionId}/teleport-events` 216: const headers = { 217: ...getOAuthHeaders(accessToken), 218: 'x-organization-uuid': orgUUID, 219: } 220: logForDebugging(`[teleport] Fetching events from: ${baseUrl}`) 221: const all: Entry[] = [] 222: let cursor: string | undefined 223: let pages = 0 224: const maxPages = 100 225: while (pages < maxPages) { 226: const params: Record<string, string | number> = { limit: 1000 } 227: if (cursor !== undefined) { 228: params.cursor = cursor 229: } 230: let response 231: try { 232: response = await axios.get<TeleportEventsResponse>(baseUrl, { 233: headers, 234: params, 235: timeout: 20000, 236: validateStatus: status => status < 500, 237: }) 238: } catch (e) { 239: const err = e as AxiosError 240: logError(new Error(`Teleport events fetch failed: ${err.message}`)) 241: logForDiagnosticsNoPII('error', 'teleport_events_fetch_fail') 242: return null 243: } 244: if (response.status === 404) { 245: logForDebugging( 246: `[teleport] Session ${sessionId} not found (page ${pages})`, 247: ) 248: logForDiagnosticsNoPII('warn', 'teleport_events_not_found') 249: return pages === 0 ? null : all 250: } 251: if (response.status === 401) { 252: logForDiagnosticsNoPII('error', 'teleport_events_bad_token') 253: throw new Error( 254: 'Your session has expired. Please run /login to sign in again.', 255: ) 256: } 257: if (response.status !== 200) { 258: logError( 259: new Error( 260: `Teleport events returned ${response.status}: ${jsonStringify(response.data)}`, 261: ), 262: ) 263: logForDiagnosticsNoPII('error', 'teleport_events_bad_status') 264: return null 265: } 266: const { data, next_cursor } = response.data 267: if (!Array.isArray(data)) { 268: logError( 269: new Error( 270: `Teleport events invalid response shape: ${jsonStringify(response.data)}`, 271: ), 272: ) 273: logForDiagnosticsNoPII('error', 'teleport_events_invalid_shape') 274: return null 275: } 276: for (const ev of data) { 277: if (ev.payload !== null) { 278: all.push(ev.payload) 279: } 280: } 281: pages++ 282: if (next_cursor == null) { 283: break 284: } 285: cursor = next_cursor 286: } 287: if (pages >= maxPages) { 288: logError( 289: new Error(`Teleport events hit page cap (${maxPages}) for ${sessionId}`), 290: ) 291: logForDiagnosticsNoPII('warn', 'teleport_events_page_cap') 292: } 293: logForDebugging( 294: `[teleport] Fetched ${all.length} events over ${pages} page(s) for ${sessionId}`, 295: ) 296: return all 297: } 298: async function fetchSessionLogsFromUrl( 299: sessionId: string, 300: url: string, 301: headers: Record<string, string>, 302: ): Promise<Entry[] | null> { 303: try { 304: const response = await axios.get(url, { 305: headers, 306: timeout: 20000, 307: validateStatus: status => status < 500, 308: params: isEnvTruthy(process.env.CLAUDE_AFTER_LAST_COMPACT) 309: ? { after_last_compact: true } 310: : undefined, 311: }) 312: if (response.status === 200) { 313: const data = response.data 314: if (!data || typeof data !== 'object' || !Array.isArray(data.loglines)) { 315: logError( 316: new Error( 317: `Invalid session logs response format: ${jsonStringify(data)}`, 318: ), 319: ) 320: logForDiagnosticsNoPII('error', 'session_get_fail_invalid_response') 321: return null 322: } 323: const logs = data.loglines as Entry[] 324: logForDebugging( 325: `Fetched ${logs.length} session logs for session ${sessionId}`, 326: ) 327: return logs 328: } 329: if (response.status === 404) { 330: logForDebugging(`No existing logs for session ${sessionId}`) 331: logForDiagnosticsNoPII('warn', 'session_get_no_logs_for_session') 332: return [] 333: } 334: if (response.status === 401) { 335: logForDebugging('Auth token expired or invalid') 336: logForDiagnosticsNoPII('error', 'session_get_fail_bad_token') 337: throw new Error( 338: 'Your session has expired. Please run /login to sign in again.', 339: ) 340: } 341: logForDebugging( 342: `Failed to fetch session logs: ${response.status} ${response.statusText}`, 343: ) 344: logForDiagnosticsNoPII('error', 'session_get_fail_status', { 345: status: response.status, 346: }) 347: return null 348: } catch (error) { 349: const axiosError = error as AxiosError<SessionIngressError> 350: logError(new Error(`Error fetching session logs: ${axiosError.message}`)) 351: logForDiagnosticsNoPII('error', 'session_get_fail_status', { 352: status: axiosError.status, 353: }) 354: return null 355: } 356: } 357: function findLastUuid(logs: Entry[] | null): UUID | undefined { 358: if (!logs) { 359: return undefined 360: } 361: const entry = logs.findLast(e => 'uuid' in e && e.uuid) 362: return entry && 'uuid' in entry ? (entry.uuid as UUID) : undefined 363: } 364: export function clearSession(sessionId: string): void { 365: lastUuidMap.delete(sessionId) 366: sequentialAppendBySession.delete(sessionId) 367: } 368: export function clearAllSessions(): void { 369: lastUuidMap.clear() 370: sequentialAppendBySession.clear() 371: }

File: src/services/api/ultrareviewQuota.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { isClaudeAISubscriber } from '../../utils/auth.js' 4: import { logForDebugging } from '../../utils/debug.js' 5: import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js' 6: export type UltrareviewQuotaResponse = { 7: reviews_used: number 8: reviews_limit: number 9: reviews_remaining: number 10: is_overage: boolean 11: } 12: export async function fetchUltrareviewQuota(): Promise<UltrareviewQuotaResponse | null> { 13: if (!isClaudeAISubscriber()) return null 14: try { 15: const { accessToken, orgUUID } = await prepareApiRequest() 16: const response = await axios.get<UltrareviewQuotaResponse>( 17: `${getOauthConfig().BASE_API_URL}/v1/ultrareview/quota`, 18: { 19: headers: { 20: ...getOAuthHeaders(accessToken), 21: 'x-organization-uuid': orgUUID, 22: }, 23: timeout: 5000, 24: }, 25: ) 26: return response.data 27: } catch (error) { 28: logForDebugging(`fetchUltrareviewQuota failed: ${error}`) 29: return null 30: } 31: }

File: src/services/api/usage.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from '../../constants/oauth.js' 3: import { 4: getClaudeAIOAuthTokens, 5: hasProfileScope, 6: isClaudeAISubscriber, 7: } from '../../utils/auth.js' 8: import { getAuthHeaders } from '../../utils/http.js' 9: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 10: import { isOAuthTokenExpired } from '../oauth/client.js' 11: export type RateLimit = { 12: utilization: number | null 13: resets_at: string | null 14: } 15: export type ExtraUsage = { 16: is_enabled: boolean 17: monthly_limit: number | null 18: used_credits: number | null 19: utilization: number | null 20: } 21: export type Utilization = { 22: five_hour?: RateLimit | null 23: seven_day?: RateLimit | null 24: seven_day_oauth_apps?: RateLimit | null 25: seven_day_opus?: RateLimit | null 26: seven_day_sonnet?: RateLimit | null 27: extra_usage?: ExtraUsage | null 28: } 29: export async function fetchUtilization(): Promise<Utilization | null> { 30: if (!isClaudeAISubscriber() || !hasProfileScope()) { 31: return {} 32: } 33: const tokens = getClaudeAIOAuthTokens() 34: if (tokens && isOAuthTokenExpired(tokens.expiresAt)) { 35: return null 36: } 37: const authResult = getAuthHeaders() 38: if (authResult.error) { 39: throw new Error(`Auth error: ${authResult.error}`) 40: } 41: const headers = { 42: 'Content-Type': 'application/json', 43: 'User-Agent': getClaudeCodeUserAgent(), 44: ...authResult.headers, 45: } 46: const url = `${getOauthConfig().BASE_API_URL}/api/oauth/usage` 47: const response = await axios.get<Utilization>(url, { 48: headers, 49: timeout: 5000, 50: }) 51: return response.data 52: }

File: src/services/api/withRetry.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type Anthropic from '@anthropic-ai/sdk' 3: import { 4: APIConnectionError, 5: APIError, 6: APIUserAbortError, 7: } from '@anthropic-ai/sdk' 8: import type { QuerySource } from 'src/constants/querySource.js' 9: import type { SystemAPIErrorMessage } from 'src/types/message.js' 10: import { isAwsCredentialsProviderError } from 'src/utils/aws.js' 11: import { logForDebugging } from 'src/utils/debug.js' 12: import { logError } from 'src/utils/log.js' 13: import { createSystemAPIErrorMessage } from 'src/utils/messages.js' 14: import { getAPIProviderForStatsig } from 'src/utils/model/providers.js' 15: import { 16: clearApiKeyHelperCache, 17: clearAwsCredentialsCache, 18: clearGcpCredentialsCache, 19: getClaudeAIOAuthTokens, 20: handleOAuth401Error, 21: isClaudeAISubscriber, 22: isEnterpriseSubscriber, 23: } from '../../utils/auth.js' 24: import { isEnvTruthy } from '../../utils/envUtils.js' 25: import { errorMessage } from '../../utils/errors.js' 26: import { 27: type CooldownReason, 28: handleFastModeOverageRejection, 29: handleFastModeRejectedByAPI, 30: isFastModeCooldown, 31: isFastModeEnabled, 32: triggerFastModeCooldown, 33: } from '../../utils/fastMode.js' 34: import { isNonCustomOpusModel } from '../../utils/model/model.js' 35: import { disableKeepAlive } from '../../utils/proxy.js' 36: import { sleep } from '../../utils/sleep.js' 37: import type { ThinkingConfig } from '../../utils/thinking.js' 38: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 39: import { 40: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 41: logEvent, 42: } from '../analytics/index.js' 43: import { 44: checkMockRateLimitError, 45: isMockRateLimitError, 46: } from '../rateLimitMocking.js' 47: import { REPEATED_529_ERROR_MESSAGE } from './errors.js' 48: import { extractConnectionErrorDetails } from './errorUtils.js' 49: const abortError = () => new APIUserAbortError() 50: const DEFAULT_MAX_RETRIES = 10 51: const FLOOR_OUTPUT_TOKENS = 3000 52: const MAX_529_RETRIES = 3 53: export const BASE_DELAY_MS = 500 54: const FOREGROUND_529_RETRY_SOURCES = new Set<QuerySource>([ 55: 'repl_main_thread', 56: 'repl_main_thread:outputStyle:custom', 57: 'repl_main_thread:outputStyle:Explanatory', 58: 'repl_main_thread:outputStyle:Learning', 59: 'sdk', 60: 'agent:custom', 61: 'agent:default', 62: 'agent:builtin', 63: 'compact', 64: 'hook_agent', 65: 'hook_prompt', 66: 'verification_agent', 67: 'side_question', 68: 'auto_mode', 69: ...(feature('BASH_CLASSIFIER') ? (['bash_classifier'] as const) : []), 70: ]) 71: function shouldRetry529(querySource: QuerySource | undefined): boolean { 72: return ( 73: querySource === undefined || FOREGROUND_529_RETRY_SOURCES.has(querySource) 74: ) 75: } 76: const PERSISTENT_MAX_BACKOFF_MS = 5 * 60 * 1000 77: const PERSISTENT_RESET_CAP_MS = 6 * 60 * 60 * 1000 78: const HEARTBEAT_INTERVAL_MS = 30_000 79: function isPersistentRetryEnabled(): boolean { 80: return feature('UNATTENDED_RETRY') 81: ? isEnvTruthy(process.env.CLAUDE_CODE_UNATTENDED_RETRY) 82: : false 83: } 84: function isTransientCapacityError(error: unknown): boolean { 85: return ( 86: is529Error(error) || (error instanceof APIError && error.status === 429) 87: ) 88: } 89: function isStaleConnectionError(error: unknown): boolean { 90: if (!(error instanceof APIConnectionError)) { 91: return false 92: } 93: const details = extractConnectionErrorDetails(error) 94: return details?.code === 'ECONNRESET' || details?.code === 'EPIPE' 95: } 96: export interface RetryContext { 97: maxTokensOverride?: number 98: model: string 99: thinkingConfig: ThinkingConfig 100: fastMode?: boolean 101: } 102: interface RetryOptions { 103: maxRetries?: number 104: model: string 105: fallbackModel?: string 106: thinkingConfig: ThinkingConfig 107: fastMode?: boolean 108: signal?: AbortSignal 109: querySource?: QuerySource 110: initialConsecutive529Errors?: number 111: } 112: export class CannotRetryError extends Error { 113: constructor( 114: public readonly originalError: unknown, 115: public readonly retryContext: RetryContext, 116: ) { 117: const message = errorMessage(originalError) 118: super(message) 119: this.name = 'RetryError' 120: if (originalError instanceof Error && originalError.stack) { 121: this.stack = originalError.stack 122: } 123: } 124: } 125: export class FallbackTriggeredError extends Error { 126: constructor( 127: public readonly originalModel: string, 128: public readonly fallbackModel: string, 129: ) { 130: super(`Model fallback triggered: ${originalModel} -> ${fallbackModel}`) 131: this.name = 'FallbackTriggeredError' 132: } 133: } 134: export async function* withRetry<T>( 135: getClient: () => Promise<Anthropic>, 136: operation: ( 137: client: Anthropic, 138: attempt: number, 139: context: RetryContext, 140: ) => Promise<T>, 141: options: RetryOptions, 142: ): AsyncGenerator<SystemAPIErrorMessage, T> { 143: const maxRetries = getMaxRetries(options) 144: const retryContext: RetryContext = { 145: model: options.model, 146: thinkingConfig: options.thinkingConfig, 147: ...(isFastModeEnabled() && { fastMode: options.fastMode }), 148: } 149: let client: Anthropic | null = null 150: let consecutive529Errors = options.initialConsecutive529Errors ?? 0 151: let lastError: unknown 152: let persistentAttempt = 0 153: for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { 154: if (options.signal?.aborted) { 155: throw new APIUserAbortError() 156: } 157: const wasFastModeActive = isFastModeEnabled() 158: ? retryContext.fastMode && !isFastModeCooldown() 159: : false 160: try { 161: if (process.env.USER_TYPE === 'ant') { 162: const mockError = checkMockRateLimitError( 163: retryContext.model, 164: wasFastModeActive, 165: ) 166: if (mockError) { 167: throw mockError 168: } 169: } 170: const isStaleConnection = isStaleConnectionError(lastError) 171: if ( 172: isStaleConnection && 173: getFeatureValue_CACHED_MAY_BE_STALE( 174: 'tengu_disable_keepalive_on_econnreset', 175: false, 176: ) 177: ) { 178: logForDebugging( 179: 'Stale connection (ECONNRESET/EPIPE) — disabling keep-alive for retry', 180: ) 181: disableKeepAlive() 182: } 183: if ( 184: client === null || 185: (lastError instanceof APIError && lastError.status === 401) || 186: isOAuthTokenRevokedError(lastError) || 187: isBedrockAuthError(lastError) || 188: isVertexAuthError(lastError) || 189: isStaleConnection 190: ) { 191: if ( 192: (lastError instanceof APIError && lastError.status === 401) || 193: isOAuthTokenRevokedError(lastError) 194: ) { 195: const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken 196: if (failedAccessToken) { 197: await handleOAuth401Error(failedAccessToken) 198: } 199: } 200: client = await getClient() 201: } 202: return await operation(client, attempt, retryContext) 203: } catch (error) { 204: lastError = error 205: logForDebugging( 206: `API error (attempt ${attempt}/${maxRetries + 1}): ${error instanceof APIError ? `${error.status} ${error.message}` : errorMessage(error)}`, 207: { level: 'error' }, 208: ) 209: if ( 210: wasFastModeActive && 211: !isPersistentRetryEnabled() && 212: error instanceof APIError && 213: (error.status === 429 || is529Error(error)) 214: ) { 215: const overageReason = error.headers?.get( 216: 'anthropic-ratelimit-unified-overage-disabled-reason', 217: ) 218: if (overageReason !== null && overageReason !== undefined) { 219: handleFastModeOverageRejection(overageReason) 220: retryContext.fastMode = false 221: continue 222: } 223: const retryAfterMs = getRetryAfterMs(error) 224: if (retryAfterMs !== null && retryAfterMs < SHORT_RETRY_THRESHOLD_MS) { 225: await sleep(retryAfterMs, options.signal, { abortError }) 226: continue 227: } 228: const cooldownMs = Math.max( 229: retryAfterMs ?? DEFAULT_FAST_MODE_FALLBACK_HOLD_MS, 230: MIN_COOLDOWN_MS, 231: ) 232: const cooldownReason: CooldownReason = is529Error(error) 233: ? 'overloaded' 234: : 'rate_limit' 235: triggerFastModeCooldown(Date.now() + cooldownMs, cooldownReason) 236: if (isFastModeEnabled()) { 237: retryContext.fastMode = false 238: } 239: continue 240: } 241: if (wasFastModeActive && isFastModeNotEnabledError(error)) { 242: handleFastModeRejectedByAPI() 243: retryContext.fastMode = false 244: continue 245: } 246: if (is529Error(error) && !shouldRetry529(options.querySource)) { 247: logEvent('tengu_api_529_background_dropped', { 248: query_source: 249: options.querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 250: }) 251: throw new CannotRetryError(error, retryContext) 252: } 253: if ( 254: is529Error(error) && 255: (process.env.FALLBACK_FOR_ALL_PRIMARY_MODELS || 256: (!isClaudeAISubscriber() && isNonCustomOpusModel(options.model))) 257: ) { 258: consecutive529Errors++ 259: if (consecutive529Errors >= MAX_529_RETRIES) { 260: if (options.fallbackModel) { 261: logEvent('tengu_api_opus_fallback_triggered', { 262: original_model: 263: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 264: fallback_model: 265: options.fallbackModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 266: provider: getAPIProviderForStatsig(), 267: }) 268: throw new FallbackTriggeredError( 269: options.model, 270: options.fallbackModel, 271: ) 272: } 273: if ( 274: process.env.USER_TYPE === 'external' && 275: !process.env.IS_SANDBOX && 276: !isPersistentRetryEnabled() 277: ) { 278: logEvent('tengu_api_custom_529_overloaded_error', {}) 279: throw new CannotRetryError( 280: new Error(REPEATED_529_ERROR_MESSAGE), 281: retryContext, 282: ) 283: } 284: } 285: } 286: const persistent = 287: isPersistentRetryEnabled() && isTransientCapacityError(error) 288: if (attempt > maxRetries && !persistent) { 289: throw new CannotRetryError(error, retryContext) 290: } 291: const handledCloudAuthError = 292: handleAwsCredentialError(error) || handleGcpCredentialError(error) 293: if ( 294: !handledCloudAuthError && 295: (!(error instanceof APIError) || !shouldRetry(error)) 296: ) { 297: throw new CannotRetryError(error, retryContext) 298: } 299: if (error instanceof APIError) { 300: const overflowData = parseMaxTokensContextOverflowError(error) 301: if (overflowData) { 302: const { inputTokens, contextLimit } = overflowData 303: const safetyBuffer = 1000 304: const availableContext = Math.max( 305: 0, 306: contextLimit - inputTokens - safetyBuffer, 307: ) 308: if (availableContext < FLOOR_OUTPUT_TOKENS) { 309: logError( 310: new Error( 311: `availableContext ${availableContext} is less than FLOOR_OUTPUT_TOKENS ${FLOOR_OUTPUT_TOKENS}`, 312: ), 313: ) 314: throw error 315: } 316: const minRequired = 317: (retryContext.thinkingConfig.type === 'enabled' 318: ? retryContext.thinkingConfig.budgetTokens 319: : 0) + 1 320: const adjustedMaxTokens = Math.max( 321: FLOOR_OUTPUT_TOKENS, 322: availableContext, 323: minRequired, 324: ) 325: retryContext.maxTokensOverride = adjustedMaxTokens 326: logEvent('tengu_max_tokens_context_overflow_adjustment', { 327: inputTokens, 328: contextLimit, 329: adjustedMaxTokens, 330: attempt, 331: }) 332: continue 333: } 334: } 335: const retryAfter = getRetryAfter(error) 336: let delayMs: number 337: if (persistent && error instanceof APIError && error.status === 429) { 338: persistentAttempt++ 339: const resetDelay = getRateLimitResetDelayMs(error) 340: delayMs = 341: resetDelay ?? 342: Math.min( 343: getRetryDelay( 344: persistentAttempt, 345: retryAfter, 346: PERSISTENT_MAX_BACKOFF_MS, 347: ), 348: PERSISTENT_RESET_CAP_MS, 349: ) 350: } else if (persistent) { 351: persistentAttempt++ 352: delayMs = Math.min( 353: getRetryDelay( 354: persistentAttempt, 355: retryAfter, 356: PERSISTENT_MAX_BACKOFF_MS, 357: ), 358: PERSISTENT_RESET_CAP_MS, 359: ) 360: } else { 361: delayMs = getRetryDelay(attempt, retryAfter) 362: } 363: const reportedAttempt = persistent ? persistentAttempt : attempt 364: logEvent('tengu_api_retry', { 365: attempt: reportedAttempt, 366: delayMs: delayMs, 367: error: (error as APIError) 368: .message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 369: status: (error as APIError).status, 370: provider: getAPIProviderForStatsig(), 371: }) 372: if (persistent) { 373: if (delayMs > 60_000) { 374: logEvent('tengu_api_persistent_retry_wait', { 375: status: (error as APIError).status, 376: delayMs, 377: attempt: reportedAttempt, 378: provider: getAPIProviderForStatsig(), 379: }) 380: } 381: let remaining = delayMs 382: while (remaining > 0) { 383: if (options.signal?.aborted) throw new APIUserAbortError() 384: if (error instanceof APIError) { 385: yield createSystemAPIErrorMessage( 386: error, 387: remaining, 388: reportedAttempt, 389: maxRetries, 390: ) 391: } 392: const chunk = Math.min(remaining, HEARTBEAT_INTERVAL_MS) 393: await sleep(chunk, options.signal, { abortError }) 394: remaining -= chunk 395: } 396: if (attempt >= maxRetries) attempt = maxRetries 397: } else { 398: if (error instanceof APIError) { 399: yield createSystemAPIErrorMessage(error, delayMs, attempt, maxRetries) 400: } 401: await sleep(delayMs, options.signal, { abortError }) 402: } 403: } 404: } 405: throw new CannotRetryError(lastError, retryContext) 406: } 407: function getRetryAfter(error: unknown): string | null { 408: return ( 409: ((error as { headers?: { 'retry-after'?: string } }).headers?.[ 410: 'retry-after' 411: ] || 412: ((error as APIError).headers as Headers)?.get?.('retry-after')) ?? 413: null 414: ) 415: } 416: export function getRetryDelay( 417: attempt: number, 418: retryAfterHeader?: string | null, 419: maxDelayMs = 32000, 420: ): number { 421: if (retryAfterHeader) { 422: const seconds = parseInt(retryAfterHeader, 10) 423: if (!isNaN(seconds)) { 424: return seconds * 1000 425: } 426: } 427: const baseDelay = Math.min( 428: BASE_DELAY_MS * Math.pow(2, attempt - 1), 429: maxDelayMs, 430: ) 431: const jitter = Math.random() * 0.25 * baseDelay 432: return baseDelay + jitter 433: } 434: export function parseMaxTokensContextOverflowError(error: APIError): 435: | { 436: inputTokens: number 437: maxTokens: number 438: contextLimit: number 439: } 440: | undefined { 441: if (error.status !== 400 || !error.message) { 442: return undefined 443: } 444: if ( 445: !error.message.includes( 446: 'input length and `max_tokens` exceed context limit', 447: ) 448: ) { 449: return undefined 450: } 451: const regex = 452: /input length and `max_tokens` exceed context limit: (\d+) \+ (\d+) > (\d+)/ 453: const match = error.message.match(regex) 454: if (!match || match.length !== 4) { 455: return undefined 456: } 457: if (!match[1] || !match[2] || !match[3]) { 458: logError( 459: new Error( 460: 'Unable to parse max_tokens from max_tokens exceed context limit error message', 461: ), 462: ) 463: return undefined 464: } 465: const inputTokens = parseInt(match[1], 10) 466: const maxTokens = parseInt(match[2], 10) 467: const contextLimit = parseInt(match[3], 10) 468: if (isNaN(inputTokens) || isNaN(maxTokens) || isNaN(contextLimit)) { 469: return undefined 470: } 471: return { inputTokens, maxTokens, contextLimit } 472: } 473: function isFastModeNotEnabledError(error: unknown): boolean { 474: if (!(error instanceof APIError)) { 475: return false 476: } 477: return ( 478: error.status === 400 && 479: (error.message?.includes('Fast mode is not enabled') ?? false) 480: ) 481: } 482: export function is529Error(error: unknown): boolean { 483: if (!(error instanceof APIError)) { 484: return false 485: } 486: return ( 487: error.status === 529 || 488: (error.message?.includes('"type":"overloaded_error"') ?? false) 489: ) 490: } 491: function isOAuthTokenRevokedError(error: unknown): boolean { 492: return ( 493: error instanceof APIError && 494: error.status === 403 && 495: (error.message?.includes('OAuth token has been revoked') ?? false) 496: ) 497: } 498: function isBedrockAuthError(error: unknown): boolean { 499: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK)) { 500: if ( 501: isAwsCredentialsProviderError(error) || 502: (error instanceof APIError && error.status === 403) 503: ) { 504: return true 505: } 506: } 507: return false 508: } 509: function handleAwsCredentialError(error: unknown): boolean { 510: if (isBedrockAuthError(error)) { 511: clearAwsCredentialsCache() 512: return true 513: } 514: return false 515: } 516: function isGoogleAuthLibraryCredentialError(error: unknown): boolean { 517: if (!(error instanceof Error)) return false 518: const msg = error.message 519: return ( 520: msg.includes('Could not load the default credentials') || 521: msg.includes('Could not refresh access token') || 522: msg.includes('invalid_grant') 523: ) 524: } 525: function isVertexAuthError(error: unknown): boolean { 526: if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX)) { 527: if (isGoogleAuthLibraryCredentialError(error)) { 528: return true 529: } 530: if (error instanceof APIError && error.status === 401) { 531: return true 532: } 533: } 534: return false 535: } 536: function handleGcpCredentialError(error: unknown): boolean { 537: if (isVertexAuthError(error)) { 538: clearGcpCredentialsCache() 539: return true 540: } 541: return false 542: } 543: function shouldRetry(error: APIError): boolean { 544: if (isMockRateLimitError(error)) { 545: return false 546: } 547: if (isPersistentRetryEnabled() && isTransientCapacityError(error)) { 548: return true 549: } 550: if ( 551: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) && 552: (error.status === 401 || error.status === 403) 553: ) { 554: return true 555: } 556: if (error.message?.includes('"type":"overloaded_error"')) { 557: return true 558: } 559: if (parseMaxTokensContextOverflowError(error)) { 560: return true 561: } 562: const shouldRetryHeader = error.headers?.get('x-should-retry') 563: if ( 564: shouldRetryHeader === 'true' && 565: (!isClaudeAISubscriber() || isEnterpriseSubscriber()) 566: ) { 567: return true 568: } 569: if (shouldRetryHeader === 'false') { 570: const is5xxError = error.status !== undefined && error.status >= 500 571: if (!(process.env.USER_TYPE === 'ant' && is5xxError)) { 572: return false 573: } 574: } 575: if (error instanceof APIConnectionError) { 576: return true 577: } 578: if (!error.status) return false 579: if (error.status === 408) return true 580: if (error.status === 409) return true 581: if (error.status === 429) { 582: return !isClaudeAISubscriber() || isEnterpriseSubscriber() 583: } 584: if (error.status === 401) { 585: clearApiKeyHelperCache() 586: return true 587: } 588: if (isOAuthTokenRevokedError(error)) { 589: return true 590: } 591: if (error.status && error.status >= 500) return true 592: return false 593: } 594: export function getDefaultMaxRetries(): number { 595: if (process.env.CLAUDE_CODE_MAX_RETRIES) { 596: return parseInt(process.env.CLAUDE_CODE_MAX_RETRIES, 10) 597: } 598: return DEFAULT_MAX_RETRIES 599: } 600: function getMaxRetries(options: RetryOptions): number { 601: return options.maxRetries ?? getDefaultMaxRetries() 602: } 603: const DEFAULT_FAST_MODE_FALLBACK_HOLD_MS = 30 * 60 * 1000 604: const SHORT_RETRY_THRESHOLD_MS = 20 * 1000 605: const MIN_COOLDOWN_MS = 10 * 60 * 1000 606: function getRetryAfterMs(error: APIError): number | null { 607: const retryAfter = getRetryAfter(error) 608: if (retryAfter) { 609: const seconds = parseInt(retryAfter, 10) 610: if (!isNaN(seconds)) { 611: return seconds * 1000 612: } 613: } 614: return null 615: } 616: function getRateLimitResetDelayMs(error: APIError): number | null { 617: const resetHeader = error.headers?.get?.('anthropic-ratelimit-unified-reset') 618: if (!resetHeader) return null 619: const resetUnixSec = Number(resetHeader) 620: if (!Number.isFinite(resetUnixSec)) return null 621: const delayMs = resetUnixSec * 1000 - Date.now() 622: if (delayMs <= 0) return null 623: return Math.min(delayMs, PERSISTENT_RESET_CAP_MS) 624: }

File: src/services/autoDream/autoDream.ts

typescript 1: import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' 2: import { 3: createCacheSafeParams, 4: runForkedAgent, 5: } from '../../utils/forkedAgent.js' 6: import { 7: createUserMessage, 8: createMemorySavedMessage, 9: } from '../../utils/messages.js' 10: import type { Message } from '../../types/message.js' 11: import { logForDebugging } from '../../utils/debug.js' 12: import type { ToolUseContext } from '../../Tool.js' 13: import { logEvent } from '../analytics/index.js' 14: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 15: import { isAutoMemoryEnabled, getAutoMemPath } from '../../memdir/paths.js' 16: import { isAutoDreamEnabled } from './config.js' 17: import { getProjectDir } from '../../utils/sessionStorage.js' 18: import { 19: getOriginalCwd, 20: getKairosActive, 21: getIsRemoteMode, 22: getSessionId, 23: } from '../../bootstrap/state.js' 24: import { createAutoMemCanUseTool } from '../extractMemories/extractMemories.js' 25: import { buildConsolidationPrompt } from './consolidationPrompt.js' 26: import { 27: readLastConsolidatedAt, 28: listSessionsTouchedSince, 29: tryAcquireConsolidationLock, 30: rollbackConsolidationLock, 31: } from './consolidationLock.js' 32: import { 33: registerDreamTask, 34: addDreamTurn, 35: completeDreamTask, 36: failDreamTask, 37: isDreamTask, 38: } from '../../tasks/DreamTask/DreamTask.js' 39: import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 40: import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' 41: const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000 42: type AutoDreamConfig = { 43: minHours: number 44: minSessions: number 45: } 46: const DEFAULTS: AutoDreamConfig = { 47: minHours: 24, 48: minSessions: 5, 49: } 50: function getConfig(): AutoDreamConfig { 51: const raw = 52: getFeatureValue_CACHED_MAY_BE_STALE<Partial<AutoDreamConfig> | null>( 53: 'tengu_onyx_plover', 54: null, 55: ) 56: return { 57: minHours: 58: typeof raw?.minHours === 'number' && 59: Number.isFinite(raw.minHours) && 60: raw.minHours > 0 61: ? raw.minHours 62: : DEFAULTS.minHours, 63: minSessions: 64: typeof raw?.minSessions === 'number' && 65: Number.isFinite(raw.minSessions) && 66: raw.minSessions > 0 67: ? raw.minSessions 68: : DEFAULTS.minSessions, 69: } 70: } 71: function isGateOpen(): boolean { 72: if (getKairosActive()) return false 73: if (getIsRemoteMode()) return false 74: if (!isAutoMemoryEnabled()) return false 75: return isAutoDreamEnabled() 76: } 77: function isForced(): boolean { 78: return false 79: } 80: type AppendSystemMessageFn = NonNullable<ToolUseContext['appendSystemMessage']> 81: let runner: 82: | (( 83: context: REPLHookContext, 84: appendSystemMessage?: AppendSystemMessageFn, 85: ) => Promise<void>) 86: | null = null 87: export function initAutoDream(): void { 88: let lastSessionScanAt = 0 89: runner = async function runAutoDream(context, appendSystemMessage) { 90: const cfg = getConfig() 91: const force = isForced() 92: if (!force && !isGateOpen()) return 93: let lastAt: number 94: try { 95: lastAt = await readLastConsolidatedAt() 96: } catch (e: unknown) { 97: logForDebugging( 98: `[autoDream] readLastConsolidatedAt failed: ${(e as Error).message}`, 99: ) 100: return 101: } 102: const hoursSince = (Date.now() - lastAt) / 3_600_000 103: if (!force && hoursSince < cfg.minHours) return 104: const sinceScanMs = Date.now() - lastSessionScanAt 105: if (!force && sinceScanMs < SESSION_SCAN_INTERVAL_MS) { 106: logForDebugging( 107: `[autoDream] scan throttle — time-gate passed but last scan was ${Math.round(sinceScanMs / 1000)}s ago`, 108: ) 109: return 110: } 111: lastSessionScanAt = Date.now() 112: let sessionIds: string[] 113: try { 114: sessionIds = await listSessionsTouchedSince(lastAt) 115: } catch (e: unknown) { 116: logForDebugging( 117: `[autoDream] listSessionsTouchedSince failed: ${(e as Error).message}`, 118: ) 119: return 120: } 121: const currentSession = getSessionId() 122: sessionIds = sessionIds.filter(id => id !== currentSession) 123: if (!force && sessionIds.length < cfg.minSessions) { 124: logForDebugging( 125: `[autoDream] skip — ${sessionIds.length} sessions since last consolidation, need ${cfg.minSessions}`, 126: ) 127: return 128: } 129: let priorMtime: number | null 130: if (force) { 131: priorMtime = lastAt 132: } else { 133: try { 134: priorMtime = await tryAcquireConsolidationLock() 135: } catch (e: unknown) { 136: logForDebugging( 137: `[autoDream] lock acquire failed: ${(e as Error).message}`, 138: ) 139: return 140: } 141: if (priorMtime === null) return 142: } 143: logForDebugging( 144: `[autoDream] firing — ${hoursSince.toFixed(1)}h since last, ${sessionIds.length} sessions to review`, 145: ) 146: logEvent('tengu_auto_dream_fired', { 147: hours_since: Math.round(hoursSince), 148: sessions_since: sessionIds.length, 149: }) 150: const setAppState = 151: context.toolUseContext.setAppStateForTasks ?? 152: context.toolUseContext.setAppState 153: const abortController = new AbortController() 154: const taskId = registerDreamTask(setAppState, { 155: sessionsReviewing: sessionIds.length, 156: priorMtime, 157: abortController, 158: }) 159: try { 160: const memoryRoot = getAutoMemPath() 161: const transcriptDir = getProjectDir(getOriginalCwd()) 162: const extra = ` 163: **Tool constraints for this run:** Bash is restricted to read-only commands (\`ls\`, \`find\`, \`grep\`, \`cat\`, \`stat\`, \`wc\`, \`head\`, \`tail\`, and similar). Anything that writes, redirects to a file, or modifies state will be denied. Plan your exploration with this in mind — no need to probe. 164: Sessions since last consolidation (${sessionIds.length}): 165: ${sessionIds.map(id => `- ${id}`).join('\n')}` 166: const prompt = buildConsolidationPrompt(memoryRoot, transcriptDir, extra) 167: const result = await runForkedAgent({ 168: promptMessages: [createUserMessage({ content: prompt })], 169: cacheSafeParams: createCacheSafeParams(context), 170: canUseTool: createAutoMemCanUseTool(memoryRoot), 171: querySource: 'auto_dream', 172: forkLabel: 'auto_dream', 173: skipTranscript: true, 174: overrides: { abortController }, 175: onMessage: makeDreamProgressWatcher(taskId, setAppState), 176: }) 177: completeDreamTask(taskId, setAppState) 178: const dreamState = context.toolUseContext.getAppState().tasks?.[taskId] 179: if ( 180: appendSystemMessage && 181: isDreamTask(dreamState) && 182: dreamState.filesTouched.length > 0 183: ) { 184: appendSystemMessage({ 185: ...createMemorySavedMessage(dreamState.filesTouched), 186: verb: 'Improved', 187: }) 188: } 189: logForDebugging( 190: `[autoDream] completed — cache: read=${result.totalUsage.cache_read_input_tokens} created=${result.totalUsage.cache_creation_input_tokens}`, 191: ) 192: logEvent('tengu_auto_dream_completed', { 193: cache_read: result.totalUsage.cache_read_input_tokens, 194: cache_created: result.totalUsage.cache_creation_input_tokens, 195: output: result.totalUsage.output_tokens, 196: sessions_reviewed: sessionIds.length, 197: }) 198: } catch (e: unknown) { 199: if (abortController.signal.aborted) { 200: logForDebugging('[autoDream] aborted by user') 201: return 202: } 203: logForDebugging(`[autoDream] fork failed: ${(e as Error).message}`) 204: logEvent('tengu_auto_dream_failed', {}) 205: failDreamTask(taskId, setAppState) 206: await rollbackConsolidationLock(priorMtime) 207: } 208: } 209: } 210: function makeDreamProgressWatcher( 211: taskId: string, 212: setAppState: import('../../Task.js').SetAppState, 213: ): (msg: Message) => void { 214: return msg => { 215: if (msg.type !== 'assistant') return 216: let text = '' 217: let toolUseCount = 0 218: const touchedPaths: string[] = [] 219: for (const block of msg.message.content) { 220: if (block.type === 'text') { 221: text += block.text 222: } else if (block.type === 'tool_use') { 223: toolUseCount++ 224: if ( 225: block.name === FILE_EDIT_TOOL_NAME || 226: block.name === FILE_WRITE_TOOL_NAME 227: ) { 228: const input = block.input as { file_path?: unknown } 229: if (typeof input.file_path === 'string') { 230: touchedPaths.push(input.file_path) 231: } 232: } 233: } 234: } 235: addDreamTurn( 236: taskId, 237: { text: text.trim(), toolUseCount }, 238: touchedPaths, 239: setAppState, 240: ) 241: } 242: } 243: export async function executeAutoDream( 244: context: REPLHookContext, 245: appendSystemMessage?: AppendSystemMessageFn, 246: ): Promise<void> { 247: await runner?.(context, appendSystemMessage) 248: }

File: src/services/autoDream/config.ts

typescript 1: import { getInitialSettings } from '../../utils/settings/settings.js' 2: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 3: export function isAutoDreamEnabled(): boolean { 4: const setting = getInitialSettings().autoDreamEnabled 5: if (setting !== undefined) return setting 6: const gb = getFeatureValue_CACHED_MAY_BE_STALE<{ enabled?: unknown } | null>( 7: 'tengu_onyx_plover', 8: null, 9: ) 10: return gb?.enabled === true 11: }

File: src/services/autoDream/consolidationLock.ts

typescript 1: import { mkdir, readFile, stat, unlink, utimes, writeFile } from 'fs/promises' 2: import { join } from 'path' 3: import { getOriginalCwd } from '../../bootstrap/state.js' 4: import { getAutoMemPath } from '../../memdir/paths.js' 5: import { logForDebugging } from '../../utils/debug.js' 6: import { isProcessRunning } from '../../utils/genericProcessUtils.js' 7: import { listCandidates } from '../../utils/listSessionsImpl.js' 8: import { getProjectDir } from '../../utils/sessionStorage.js' 9: const LOCK_FILE = '.consolidate-lock' 10: const HOLDER_STALE_MS = 60 * 60 * 1000 11: function lockPath(): string { 12: return join(getAutoMemPath(), LOCK_FILE) 13: } 14: export async function readLastConsolidatedAt(): Promise<number> { 15: try { 16: const s = await stat(lockPath()) 17: return s.mtimeMs 18: } catch { 19: return 0 20: } 21: } 22: export async function tryAcquireConsolidationLock(): Promise<number | null> { 23: const path = lockPath() 24: let mtimeMs: number | undefined 25: let holderPid: number | undefined 26: try { 27: const [s, raw] = await Promise.all([stat(path), readFile(path, 'utf8')]) 28: mtimeMs = s.mtimeMs 29: const parsed = parseInt(raw.trim(), 10) 30: holderPid = Number.isFinite(parsed) ? parsed : undefined 31: } catch { 32: } 33: if (mtimeMs !== undefined && Date.now() - mtimeMs < HOLDER_STALE_MS) { 34: if (holderPid !== undefined && isProcessRunning(holderPid)) { 35: logForDebugging( 36: `[autoDream] lock held by live PID ${holderPid} (mtime ${Math.round((Date.now() - mtimeMs) / 1000)}s ago)`, 37: ) 38: return null 39: } 40: } 41: await mkdir(getAutoMemPath(), { recursive: true }) 42: await writeFile(path, String(process.pid)) 43: let verify: string 44: try { 45: verify = await readFile(path, 'utf8') 46: } catch { 47: return null 48: } 49: if (parseInt(verify.trim(), 10) !== process.pid) return null 50: return mtimeMs ?? 0 51: } 52: export async function rollbackConsolidationLock( 53: priorMtime: number, 54: ): Promise<void> { 55: const path = lockPath() 56: try { 57: if (priorMtime === 0) { 58: await unlink(path) 59: return 60: } 61: await writeFile(path, '') 62: const t = priorMtime / 1000 // utimes wants seconds 63: await utimes(path, t, t) 64: } catch (e: unknown) { 65: logForDebugging( 66: `[autoDream] rollback failed: ${(e as Error).message} — next trigger delayed to minHours`, 67: ) 68: } 69: } 70: /** 71: * Session IDs with mtime after sinceMs. listCandidates handles UUID 72: * validation (excludes agent-*.jsonl) and parallel stat. 73: * 74: * Uses mtime (sessions TOUCHED since), not birthtime (0 on ext4). 75: * Caller excludes the current session. Scans per-cwd transcripts — it's 76: * a skip-gate, so undercounting worktree sessions is safe. 77: */ 78: export async function listSessionsTouchedSince( 79: sinceMs: number, 80: ): Promise<string[]> { 81: const dir = getProjectDir(getOriginalCwd()) 82: const candidates = await listCandidates(dir, true) 83: return candidates.filter(c => c.mtime > sinceMs).map(c => c.sessionId) 84: } 85: export async function recordConsolidation(): Promise<void> { 86: try { 87: await mkdir(getAutoMemPath(), { recursive: true }) 88: await writeFile(lockPath(), String(process.pid)) 89: } catch (e: unknown) { 90: logForDebugging( 91: `[autoDream] recordConsolidation write failed: ${(e as Error).message}`, 92: ) 93: } 94: }

File: src/services/autoDream/consolidationPrompt.ts

typescript 1: import { 2: DIR_EXISTS_GUIDANCE, 3: ENTRYPOINT_NAME, 4: MAX_ENTRYPOINT_LINES, 5: } from '../../memdir/memdir.js' 6: export function buildConsolidationPrompt( 7: memoryRoot: string, 8: transcriptDir: string, 9: extra: string, 10: ): string { 11: return `# Dream: Memory Consolidation 12: You are performing a dream — a reflective pass over your memory files. Synthesize what you've learned recently into durable, well-organized memories so that future sessions can orient quickly. 13: Memory directory: \`${memoryRoot}\` 14: ${DIR_EXISTS_GUIDANCE} 15: Session transcripts: \`${transcriptDir}\` (large JSONL files — grep narrowly, don't read whole files) 16: --- 17: ## Phase 1 — Orient 18: - \`ls\` the memory directory to see what already exists 19: - Read \`${ENTRYPOINT_NAME}\` to understand the current index 20: - Skim existing topic files so you improve them rather than creating duplicates 21: - If \`logs/\` or \`sessions/\` subdirectories exist (assistant-mode layout), review recent entries there 22: ## Phase 2 — Gather recent signal 23: Look for new information worth persisting. Sources in rough priority order: 24: 1. **Daily logs** (\`logs/YYYY/MM/YYYY-MM-DD.md\`) if present — these are the append-only stream 25: 2. **Existing memories that drifted** — facts that contradict something you see in the codebase now 26: 3. **Transcript search** — if you need specific context (e.g., "what was the error message from yesterday's build failure?"), grep the JSONL transcripts for narrow terms: 27: \`grep -rn "<narrow term>" ${transcriptDir}/ --include="*.jsonl" | tail -50\` 28: Don't exhaustively read transcripts. Look only for things you already suspect matter. 29: ## Phase 3 — Consolidate 30: For each thing worth remembering, write or update a memory file at the top level of the memory directory. Use the memory file format and type conventions from your system prompt's auto-memory section — it's the source of truth for what to save, how to structure it, and what NOT to save. 31: Focus on: 32: - Merging new signal into existing topic files rather than creating near-duplicates 33: - Converting relative dates ("yesterday", "last week") to absolute dates so they remain interpretable after time passes 34: - Deleting contradicted facts — if today's investigation disproves an old memory, fix it at the source 35: ## Phase 4 — Prune and index 36: Update \`${ENTRYPOINT_NAME}\` so it stays under ${MAX_ENTRYPOINT_LINES} lines AND under ~25KB. It's an **index**, not a dump — each entry should be one line under ~150 characters: \`- [Title](file.md) — one-line hook\`. Never write memory content directly into it. 37: - Remove pointers to memories that are now stale, wrong, or superseded 38: - Demote verbose entries: if an index line is over ~200 chars, it's carrying content that belongs in the topic file — shorten the line, move the detail 39: - Add pointers to newly important memories 40: - Resolve contradictions — if two files disagree, fix the wrong one 41: --- 42: Return a brief summary of what you consolidated, updated, or pruned. If nothing changed (memories are already tight), say so.${extra ? `\n\n## Additional context\n\n${extra}` : ''}` 43: }

File: src/services/compact/apiMicrocompact.ts

typescript 1: import { FILE_EDIT_TOOL_NAME } from 'src/tools/FileEditTool/constants.js' 2: import { FILE_READ_TOOL_NAME } from 'src/tools/FileReadTool/prompt.js' 3: import { FILE_WRITE_TOOL_NAME } from 'src/tools/FileWriteTool/prompt.js' 4: import { GLOB_TOOL_NAME } from 'src/tools/GlobTool/prompt.js' 5: import { GREP_TOOL_NAME } from 'src/tools/GrepTool/prompt.js' 6: import { NOTEBOOK_EDIT_TOOL_NAME } from 'src/tools/NotebookEditTool/constants.js' 7: import { WEB_FETCH_TOOL_NAME } from 'src/tools/WebFetchTool/prompt.js' 8: import { WEB_SEARCH_TOOL_NAME } from 'src/tools/WebSearchTool/prompt.js' 9: import { SHELL_TOOL_NAMES } from 'src/utils/shell/shellToolUtils.js' 10: import { isEnvTruthy } from '../../utils/envUtils.js' 11: const DEFAULT_MAX_INPUT_TOKENS = 180_000 12: const DEFAULT_TARGET_INPUT_TOKENS = 40_000 13: const TOOLS_CLEARABLE_RESULTS = [ 14: ...SHELL_TOOL_NAMES, 15: GLOB_TOOL_NAME, 16: GREP_TOOL_NAME, 17: FILE_READ_TOOL_NAME, 18: WEB_FETCH_TOOL_NAME, 19: WEB_SEARCH_TOOL_NAME, 20: ] 21: const TOOLS_CLEARABLE_USES = [ 22: FILE_EDIT_TOOL_NAME, 23: FILE_WRITE_TOOL_NAME, 24: NOTEBOOK_EDIT_TOOL_NAME, 25: ] 26: export type ContextEditStrategy = 27: | { 28: type: 'clear_tool_uses_20250919' 29: trigger?: { 30: type: 'input_tokens' 31: value: number 32: } 33: keep?: { 34: type: 'tool_uses' 35: value: number 36: } 37: clear_tool_inputs?: boolean | string[] 38: exclude_tools?: string[] 39: clear_at_least?: { 40: type: 'input_tokens' 41: value: number 42: } 43: } 44: | { 45: type: 'clear_thinking_20251015' 46: keep: { type: 'thinking_turns'; value: number } | 'all' 47: } 48: export type ContextManagementConfig = { 49: edits: ContextEditStrategy[] 50: } 51: export function getAPIContextManagement(options?: { 52: hasThinking?: boolean 53: isRedactThinkingActive?: boolean 54: clearAllThinking?: boolean 55: }): ContextManagementConfig | undefined { 56: const { 57: hasThinking = false, 58: isRedactThinkingActive = false, 59: clearAllThinking = false, 60: } = options ?? {} 61: const strategies: ContextEditStrategy[] = [] 62: if (hasThinking && !isRedactThinkingActive) { 63: strategies.push({ 64: type: 'clear_thinking_20251015', 65: keep: clearAllThinking ? { type: 'thinking_turns', value: 1 } : 'all', 66: }) 67: } 68: if (process.env.USER_TYPE !== 'ant') { 69: return strategies.length > 0 ? { edits: strategies } : undefined 70: } 71: const useClearToolResults = isEnvTruthy( 72: process.env.USE_API_CLEAR_TOOL_RESULTS, 73: ) 74: const useClearToolUses = isEnvTruthy(process.env.USE_API_CLEAR_TOOL_USES) 75: if (!useClearToolResults && !useClearToolUses) { 76: return strategies.length > 0 ? { edits: strategies } : undefined 77: } 78: if (useClearToolResults) { 79: const triggerThreshold = process.env.API_MAX_INPUT_TOKENS 80: ? parseInt(process.env.API_MAX_INPUT_TOKENS) 81: : DEFAULT_MAX_INPUT_TOKENS 82: const keepTarget = process.env.API_TARGET_INPUT_TOKENS 83: ? parseInt(process.env.API_TARGET_INPUT_TOKENS) 84: : DEFAULT_TARGET_INPUT_TOKENS 85: const strategy: ContextEditStrategy = { 86: type: 'clear_tool_uses_20250919', 87: trigger: { 88: type: 'input_tokens', 89: value: triggerThreshold, 90: }, 91: clear_at_least: { 92: type: 'input_tokens', 93: value: triggerThreshold - keepTarget, 94: }, 95: clear_tool_inputs: TOOLS_CLEARABLE_RESULTS, 96: } 97: strategies.push(strategy) 98: } 99: if (useClearToolUses) { 100: const triggerThreshold = process.env.API_MAX_INPUT_TOKENS 101: ? parseInt(process.env.API_MAX_INPUT_TOKENS) 102: : DEFAULT_MAX_INPUT_TOKENS 103: const keepTarget = process.env.API_TARGET_INPUT_TOKENS 104: ? parseInt(process.env.API_TARGET_INPUT_TOKENS) 105: : DEFAULT_TARGET_INPUT_TOKENS 106: const strategy: ContextEditStrategy = { 107: type: 'clear_tool_uses_20250919', 108: trigger: { 109: type: 'input_tokens', 110: value: triggerThreshold, 111: }, 112: clear_at_least: { 113: type: 'input_tokens', 114: value: triggerThreshold - keepTarget, 115: }, 116: exclude_tools: TOOLS_CLEARABLE_USES, 117: } 118: strategies.push(strategy) 119: } 120: return strategies.length > 0 ? { edits: strategies } : undefined 121: }

File: src/services/compact/autoCompact.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { markPostCompaction } from 'src/bootstrap/state.js' 3: import { getSdkBetas } from '../../bootstrap/state.js' 4: import type { QuerySource } from '../../constants/querySource.js' 5: import type { ToolUseContext } from '../../Tool.js' 6: import type { Message } from '../../types/message.js' 7: import { getGlobalConfig } from '../../utils/config.js' 8: import { getContextWindowForModel } from '../../utils/context.js' 9: import { logForDebugging } from '../../utils/debug.js' 10: import { isEnvTruthy } from '../../utils/envUtils.js' 11: import { hasExactErrorMessage } from '../../utils/errors.js' 12: import type { CacheSafeParams } from '../../utils/forkedAgent.js' 13: import { logError } from '../../utils/log.js' 14: import { tokenCountWithEstimation } from '../../utils/tokens.js' 15: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 16: import { getMaxOutputTokensForModel } from '../api/claude.js' 17: import { notifyCompaction } from '../api/promptCacheBreakDetection.js' 18: import { setLastSummarizedMessageId } from '../SessionMemory/sessionMemoryUtils.js' 19: import { 20: type CompactionResult, 21: compactConversation, 22: ERROR_MESSAGE_USER_ABORT, 23: type RecompactionInfo, 24: } from './compact.js' 25: import { runPostCompactCleanup } from './postCompactCleanup.js' 26: import { trySessionMemoryCompaction } from './sessionMemoryCompact.js' 27: const MAX_OUTPUT_TOKENS_FOR_SUMMARY = 20_000 28: export function getEffectiveContextWindowSize(model: string): number { 29: const reservedTokensForSummary = Math.min( 30: getMaxOutputTokensForModel(model), 31: MAX_OUTPUT_TOKENS_FOR_SUMMARY, 32: ) 33: let contextWindow = getContextWindowForModel(model, getSdkBetas()) 34: const autoCompactWindow = process.env.CLAUDE_CODE_AUTO_COMPACT_WINDOW 35: if (autoCompactWindow) { 36: const parsed = parseInt(autoCompactWindow, 10) 37: if (!isNaN(parsed) && parsed > 0) { 38: contextWindow = Math.min(contextWindow, parsed) 39: } 40: } 41: return contextWindow - reservedTokensForSummary 42: } 43: export type AutoCompactTrackingState = { 44: compacted: boolean 45: turnCounter: number 46: turnId: string 47: consecutiveFailures?: number 48: } 49: export const AUTOCOMPACT_BUFFER_TOKENS = 13_000 50: export const WARNING_THRESHOLD_BUFFER_TOKENS = 20_000 51: export const ERROR_THRESHOLD_BUFFER_TOKENS = 20_000 52: export const MANUAL_COMPACT_BUFFER_TOKENS = 3_000 53: const MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES = 3 54: export function getAutoCompactThreshold(model: string): number { 55: const effectiveContextWindow = getEffectiveContextWindowSize(model) 56: const autocompactThreshold = 57: effectiveContextWindow - AUTOCOMPACT_BUFFER_TOKENS 58: const envPercent = process.env.CLAUDE_AUTOCOMPACT_PCT_OVERRIDE 59: if (envPercent) { 60: const parsed = parseFloat(envPercent) 61: if (!isNaN(parsed) && parsed > 0 && parsed <= 100) { 62: const percentageThreshold = Math.floor( 63: effectiveContextWindow * (parsed / 100), 64: ) 65: return Math.min(percentageThreshold, autocompactThreshold) 66: } 67: } 68: return autocompactThreshold 69: } 70: export function calculateTokenWarningState( 71: tokenUsage: number, 72: model: string, 73: ): { 74: percentLeft: number 75: isAboveWarningThreshold: boolean 76: isAboveErrorThreshold: boolean 77: isAboveAutoCompactThreshold: boolean 78: isAtBlockingLimit: boolean 79: } { 80: const autoCompactThreshold = getAutoCompactThreshold(model) 81: const threshold = isAutoCompactEnabled() 82: ? autoCompactThreshold 83: : getEffectiveContextWindowSize(model) 84: const percentLeft = Math.max( 85: 0, 86: Math.round(((threshold - tokenUsage) / threshold) * 100), 87: ) 88: const warningThreshold = threshold - WARNING_THRESHOLD_BUFFER_TOKENS 89: const errorThreshold = threshold - ERROR_THRESHOLD_BUFFER_TOKENS 90: const isAboveWarningThreshold = tokenUsage >= warningThreshold 91: const isAboveErrorThreshold = tokenUsage >= errorThreshold 92: const isAboveAutoCompactThreshold = 93: isAutoCompactEnabled() && tokenUsage >= autoCompactThreshold 94: const actualContextWindow = getEffectiveContextWindowSize(model) 95: const defaultBlockingLimit = 96: actualContextWindow - MANUAL_COMPACT_BUFFER_TOKENS 97: const blockingLimitOverride = process.env.CLAUDE_CODE_BLOCKING_LIMIT_OVERRIDE 98: const parsedOverride = blockingLimitOverride 99: ? parseInt(blockingLimitOverride, 10) 100: : NaN 101: const blockingLimit = 102: !isNaN(parsedOverride) && parsedOverride > 0 103: ? parsedOverride 104: : defaultBlockingLimit 105: const isAtBlockingLimit = tokenUsage >= blockingLimit 106: return { 107: percentLeft, 108: isAboveWarningThreshold, 109: isAboveErrorThreshold, 110: isAboveAutoCompactThreshold, 111: isAtBlockingLimit, 112: } 113: } 114: export function isAutoCompactEnabled(): boolean { 115: if (isEnvTruthy(process.env.DISABLE_COMPACT)) { 116: return false 117: } 118: if (isEnvTruthy(process.env.DISABLE_AUTO_COMPACT)) { 119: return false 120: } 121: const userConfig = getGlobalConfig() 122: return userConfig.autoCompactEnabled 123: } 124: export async function shouldAutoCompact( 125: messages: Message[], 126: model: string, 127: querySource?: QuerySource, 128: snipTokensFreed = 0, 129: ): Promise<boolean> { 130: if (querySource === 'session_memory' || querySource === 'compact') { 131: return false 132: } 133: if (feature('CONTEXT_COLLAPSE')) { 134: if (querySource === 'marble_origami') { 135: return false 136: } 137: } 138: if (!isAutoCompactEnabled()) { 139: return false 140: } 141: if (feature('REACTIVE_COMPACT')) { 142: if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) { 143: return false 144: } 145: } 146: if (feature('CONTEXT_COLLAPSE')) { 147: const { isContextCollapseEnabled } = 148: require('../contextCollapse/index.js') as typeof import('../contextCollapse/index.js') 149: if (isContextCollapseEnabled()) { 150: return false 151: } 152: } 153: const tokenCount = tokenCountWithEstimation(messages) - snipTokensFreed 154: const threshold = getAutoCompactThreshold(model) 155: const effectiveWindow = getEffectiveContextWindowSize(model) 156: logForDebugging( 157: `autocompact: tokens=${tokenCount} threshold=${threshold} effectiveWindow=${effectiveWindow}${snipTokensFreed > 0 ? ` snipFreed=${snipTokensFreed}` : ''}`, 158: ) 159: const { isAboveAutoCompactThreshold } = calculateTokenWarningState( 160: tokenCount, 161: model, 162: ) 163: return isAboveAutoCompactThreshold 164: } 165: export async function autoCompactIfNeeded( 166: messages: Message[], 167: toolUseContext: ToolUseContext, 168: cacheSafeParams: CacheSafeParams, 169: querySource?: QuerySource, 170: tracking?: AutoCompactTrackingState, 171: snipTokensFreed?: number, 172: ): Promise<{ 173: wasCompacted: boolean 174: compactionResult?: CompactionResult 175: consecutiveFailures?: number 176: }> { 177: if (isEnvTruthy(process.env.DISABLE_COMPACT)) { 178: return { wasCompacted: false } 179: } 180: if ( 181: tracking?.consecutiveFailures !== undefined && 182: tracking.consecutiveFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES 183: ) { 184: return { wasCompacted: false } 185: } 186: const model = toolUseContext.options.mainLoopModel 187: const shouldCompact = await shouldAutoCompact( 188: messages, 189: model, 190: querySource, 191: snipTokensFreed, 192: ) 193: if (!shouldCompact) { 194: return { wasCompacted: false } 195: } 196: const recompactionInfo: RecompactionInfo = { 197: isRecompactionInChain: tracking?.compacted === true, 198: turnsSincePreviousCompact: tracking?.turnCounter ?? -1, 199: previousCompactTurnId: tracking?.turnId, 200: autoCompactThreshold: getAutoCompactThreshold(model), 201: querySource, 202: } 203: const sessionMemoryResult = await trySessionMemoryCompaction( 204: messages, 205: toolUseContext.agentId, 206: recompactionInfo.autoCompactThreshold, 207: ) 208: if (sessionMemoryResult) { 209: setLastSummarizedMessageId(undefined) 210: runPostCompactCleanup(querySource) 211: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 212: notifyCompaction(querySource ?? 'compact', toolUseContext.agentId) 213: } 214: markPostCompaction() 215: return { 216: wasCompacted: true, 217: compactionResult: sessionMemoryResult, 218: } 219: } 220: try { 221: const compactionResult = await compactConversation( 222: messages, 223: toolUseContext, 224: cacheSafeParams, 225: true, 226: undefined, 227: true, 228: recompactionInfo, 229: ) 230: setLastSummarizedMessageId(undefined) 231: runPostCompactCleanup(querySource) 232: return { 233: wasCompacted: true, 234: compactionResult, 235: consecutiveFailures: 0, 236: } 237: } catch (error) { 238: if (!hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT)) { 239: logError(error) 240: } 241: const prevFailures = tracking?.consecutiveFailures ?? 0 242: const nextFailures = prevFailures + 1 243: if (nextFailures >= MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES) { 244: logForDebugging( 245: `autocompact: circuit breaker tripped after ${nextFailures} consecutive failures — skipping future attempts this session`, 246: { level: 'warn' }, 247: ) 248: } 249: return { wasCompacted: false, consecutiveFailures: nextFailures } 250: } 251: }

File: src/services/compact/compact.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { UUID } from 'crypto' 3: import uniqBy from 'lodash-es/uniqBy.js' 4: const sessionTranscriptModule = feature('KAIROS') 5: ? (require('../sessionTranscript/sessionTranscript.js') as typeof import('../sessionTranscript/sessionTranscript.js')) 6: : null 7: import { APIUserAbortError } from '@anthropic-ai/sdk' 8: import { markPostCompaction } from 'src/bootstrap/state.js' 9: import { getInvokedSkillsForAgent } from '../../bootstrap/state.js' 10: import type { QuerySource } from '../../constants/querySource.js' 11: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 12: import type { Tool, ToolUseContext } from '../../Tool.js' 13: import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 14: import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js' 15: import { 16: FILE_READ_TOOL_NAME, 17: FILE_UNCHANGED_STUB, 18: } from '../../tools/FileReadTool/prompt.js' 19: import { ToolSearchTool } from '../../tools/ToolSearchTool/ToolSearchTool.js' 20: import type { AgentId } from '../../types/ids.js' 21: import type { 22: AssistantMessage, 23: AttachmentMessage, 24: HookResultMessage, 25: Message, 26: PartialCompactDirection, 27: SystemCompactBoundaryMessage, 28: SystemMessage, 29: UserMessage, 30: } from '../../types/message.js' 31: import { 32: createAttachmentMessage, 33: generateFileAttachment, 34: getAgentListingDeltaAttachment, 35: getDeferredToolsDeltaAttachment, 36: getMcpInstructionsDeltaAttachment, 37: } from '../../utils/attachments.js' 38: import { getMemoryPath } from '../../utils/config.js' 39: import { COMPACT_MAX_OUTPUT_TOKENS } from '../../utils/context.js' 40: import { 41: analyzeContext, 42: tokenStatsToStatsigMetrics, 43: } from '../../utils/contextAnalysis.js' 44: import { logForDebugging } from '../../utils/debug.js' 45: import { hasExactErrorMessage } from '../../utils/errors.js' 46: import { cacheToObject } from '../../utils/fileStateCache.js' 47: import { 48: type CacheSafeParams, 49: runForkedAgent, 50: } from '../../utils/forkedAgent.js' 51: import { 52: executePostCompactHooks, 53: executePreCompactHooks, 54: } from '../../utils/hooks.js' 55: import { logError } from '../../utils/log.js' 56: import { MEMORY_TYPE_VALUES } from '../../utils/memory/types.js' 57: import { 58: createCompactBoundaryMessage, 59: createUserMessage, 60: getAssistantMessageText, 61: getLastAssistantMessage, 62: getMessagesAfterCompactBoundary, 63: isCompactBoundaryMessage, 64: normalizeMessagesForAPI, 65: } from '../../utils/messages.js' 66: import { expandPath } from '../../utils/path.js' 67: import { getPlan, getPlanFilePath } from '../../utils/plans.js' 68: import { 69: isSessionActivityTrackingActive, 70: sendSessionActivitySignal, 71: } from '../../utils/sessionActivity.js' 72: import { processSessionStartHooks } from '../../utils/sessionStart.js' 73: import { 74: getTranscriptPath, 75: reAppendSessionMetadata, 76: } from '../../utils/sessionStorage.js' 77: import { sleep } from '../../utils/sleep.js' 78: import { jsonStringify } from '../../utils/slowOperations.js' 79: import { asSystemPrompt } from '../../utils/systemPromptType.js' 80: import { getTaskOutputPath } from '../../utils/task/diskOutput.js' 81: import { 82: getTokenUsage, 83: tokenCountFromLastAPIResponse, 84: tokenCountWithEstimation, 85: } from '../../utils/tokens.js' 86: import { 87: extractDiscoveredToolNames, 88: isToolSearchEnabled, 89: } from '../../utils/toolSearch.js' 90: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 91: import { 92: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 93: logEvent, 94: } from '../analytics/index.js' 95: import { 96: getMaxOutputTokensForModel, 97: queryModelWithStreaming, 98: } from '../api/claude.js' 99: import { 100: getPromptTooLongTokenGap, 101: PROMPT_TOO_LONG_ERROR_MESSAGE, 102: startsWithApiErrorPrefix, 103: } from '../api/errors.js' 104: import { notifyCompaction } from '../api/promptCacheBreakDetection.js' 105: import { getRetryDelay } from '../api/withRetry.js' 106: import { logPermissionContextForAnts } from '../internalLogging.js' 107: import { 108: roughTokenCountEstimation, 109: roughTokenCountEstimationForMessages, 110: } from '../tokenEstimation.js' 111: import { groupMessagesByApiRound } from './grouping.js' 112: import { 113: getCompactPrompt, 114: getCompactUserSummaryMessage, 115: getPartialCompactPrompt, 116: } from './prompt.js' 117: export const POST_COMPACT_MAX_FILES_TO_RESTORE = 5 118: export const POST_COMPACT_TOKEN_BUDGET = 50_000 119: export const POST_COMPACT_MAX_TOKENS_PER_FILE = 5_000 120: export const POST_COMPACT_MAX_TOKENS_PER_SKILL = 5_000 121: export const POST_COMPACT_SKILLS_TOKEN_BUDGET = 25_000 122: const MAX_COMPACT_STREAMING_RETRIES = 2 123: export function stripImagesFromMessages(messages: Message[]): Message[] { 124: return messages.map(message => { 125: if (message.type !== 'user') { 126: return message 127: } 128: const content = message.message.content 129: if (!Array.isArray(content)) { 130: return message 131: } 132: let hasMediaBlock = false 133: const newContent = content.flatMap(block => { 134: if (block.type === 'image') { 135: hasMediaBlock = true 136: return [{ type: 'text' as const, text: '[image]' }] 137: } 138: if (block.type === 'document') { 139: hasMediaBlock = true 140: return [{ type: 'text' as const, text: '[document]' }] 141: } 142: if (block.type === 'tool_result' && Array.isArray(block.content)) { 143: let toolHasMedia = false 144: const newToolContent = block.content.map(item => { 145: if (item.type === 'image') { 146: toolHasMedia = true 147: return { type: 'text' as const, text: '[image]' } 148: } 149: if (item.type === 'document') { 150: toolHasMedia = true 151: return { type: 'text' as const, text: '[document]' } 152: } 153: return item 154: }) 155: if (toolHasMedia) { 156: hasMediaBlock = true 157: return [{ ...block, content: newToolContent }] 158: } 159: } 160: return [block] 161: }) 162: if (!hasMediaBlock) { 163: return message 164: } 165: return { 166: ...message, 167: message: { 168: ...message.message, 169: content: newContent, 170: }, 171: } as typeof message 172: }) 173: } 174: export function stripReinjectedAttachments(messages: Message[]): Message[] { 175: if (feature('EXPERIMENTAL_SKILL_SEARCH')) { 176: return messages.filter( 177: m => 178: !( 179: m.type === 'attachment' && 180: (m.attachment.type === 'skill_discovery' || 181: m.attachment.type === 'skill_listing') 182: ), 183: ) 184: } 185: return messages 186: } 187: export const ERROR_MESSAGE_NOT_ENOUGH_MESSAGES = 188: 'Not enough messages to compact.' 189: const MAX_PTL_RETRIES = 3 190: const PTL_RETRY_MARKER = '[earlier conversation truncated for compaction retry]' 191: export function truncateHeadForPTLRetry( 192: messages: Message[], 193: ptlResponse: AssistantMessage, 194: ): Message[] | null { 195: const input = 196: messages[0]?.type === 'user' && 197: messages[0].isMeta && 198: messages[0].message.content === PTL_RETRY_MARKER 199: ? messages.slice(1) 200: : messages 201: const groups = groupMessagesByApiRound(input) 202: if (groups.length < 2) return null 203: const tokenGap = getPromptTooLongTokenGap(ptlResponse) 204: let dropCount: number 205: if (tokenGap !== undefined) { 206: let acc = 0 207: dropCount = 0 208: for (const g of groups) { 209: acc += roughTokenCountEstimationForMessages(g) 210: dropCount++ 211: if (acc >= tokenGap) break 212: } 213: } else { 214: dropCount = Math.max(1, Math.floor(groups.length * 0.2)) 215: } 216: dropCount = Math.min(dropCount, groups.length - 1) 217: if (dropCount < 1) return null 218: const sliced = groups.slice(dropCount).flat() 219: if (sliced[0]?.type === 'assistant') { 220: return [ 221: createUserMessage({ content: PTL_RETRY_MARKER, isMeta: true }), 222: ...sliced, 223: ] 224: } 225: return sliced 226: } 227: export const ERROR_MESSAGE_PROMPT_TOO_LONG = 228: 'Conversation too long. Press esc twice to go up a few messages and try again.' 229: export const ERROR_MESSAGE_USER_ABORT = 'API Error: Request was aborted.' 230: export const ERROR_MESSAGE_INCOMPLETE_RESPONSE = 231: 'Compaction interrupted · This may be due to network issues — please try again.' 232: export interface CompactionResult { 233: boundaryMarker: SystemMessage 234: summaryMessages: UserMessage[] 235: attachments: AttachmentMessage[] 236: hookResults: HookResultMessage[] 237: messagesToKeep?: Message[] 238: userDisplayMessage?: string 239: preCompactTokenCount?: number 240: postCompactTokenCount?: number 241: truePostCompactTokenCount?: number 242: compactionUsage?: ReturnType<typeof getTokenUsage> 243: } 244: export type RecompactionInfo = { 245: isRecompactionInChain: boolean 246: turnsSincePreviousCompact: number 247: previousCompactTurnId?: string 248: autoCompactThreshold: number 249: querySource?: QuerySource 250: } 251: export function buildPostCompactMessages(result: CompactionResult): Message[] { 252: return [ 253: result.boundaryMarker, 254: ...result.summaryMessages, 255: ...(result.messagesToKeep ?? []), 256: ...result.attachments, 257: ...result.hookResults, 258: ] 259: } 260: export function annotateBoundaryWithPreservedSegment( 261: boundary: SystemCompactBoundaryMessage, 262: anchorUuid: UUID, 263: messagesToKeep: readonly Message[] | undefined, 264: ): SystemCompactBoundaryMessage { 265: const keep = messagesToKeep ?? [] 266: if (keep.length === 0) return boundary 267: return { 268: ...boundary, 269: compactMetadata: { 270: ...boundary.compactMetadata, 271: preservedSegment: { 272: headUuid: keep[0]!.uuid, 273: anchorUuid, 274: tailUuid: keep.at(-1)!.uuid, 275: }, 276: }, 277: } 278: } 279: export function mergeHookInstructions( 280: userInstructions: string | undefined, 281: hookInstructions: string | undefined, 282: ): string | undefined { 283: if (!hookInstructions) return userInstructions || undefined 284: if (!userInstructions) return hookInstructions 285: return `${userInstructions}\n\n${hookInstructions}` 286: } 287: export async function compactConversation( 288: messages: Message[], 289: context: ToolUseContext, 290: cacheSafeParams: CacheSafeParams, 291: suppressFollowUpQuestions: boolean, 292: customInstructions?: string, 293: isAutoCompact: boolean = false, 294: recompactionInfo?: RecompactionInfo, 295: ): Promise<CompactionResult> { 296: try { 297: if (messages.length === 0) { 298: throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) 299: } 300: const preCompactTokenCount = tokenCountWithEstimation(messages) 301: const appState = context.getAppState() 302: void logPermissionContextForAnts(appState.toolPermissionContext, 'summary') 303: context.onCompactProgress?.({ 304: type: 'hooks_start', 305: hookType: 'pre_compact', 306: }) 307: context.setSDKStatus?.('compacting') 308: const hookResult = await executePreCompactHooks( 309: { 310: trigger: isAutoCompact ? 'auto' : 'manual', 311: customInstructions: customInstructions ?? null, 312: }, 313: context.abortController.signal, 314: ) 315: customInstructions = mergeHookInstructions( 316: customInstructions, 317: hookResult.newCustomInstructions, 318: ) 319: const userDisplayMessage = hookResult.userDisplayMessage 320: context.setStreamMode?.('requesting') 321: context.setResponseLength?.(() => 0) 322: context.onCompactProgress?.({ type: 'compact_start' }) 323: const promptCacheSharingEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 324: 'tengu_compact_cache_prefix', 325: true, 326: ) 327: const compactPrompt = getCompactPrompt(customInstructions) 328: const summaryRequest = createUserMessage({ 329: content: compactPrompt, 330: }) 331: let messagesToSummarize = messages 332: let retryCacheSafeParams = cacheSafeParams 333: let summaryResponse: AssistantMessage 334: let summary: string | null 335: let ptlAttempts = 0 336: for (;;) { 337: summaryResponse = await streamCompactSummary({ 338: messages: messagesToSummarize, 339: summaryRequest, 340: appState, 341: context, 342: preCompactTokenCount, 343: cacheSafeParams: retryCacheSafeParams, 344: }) 345: summary = getAssistantMessageText(summaryResponse) 346: if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break 347: ptlAttempts++ 348: const truncated = 349: ptlAttempts <= MAX_PTL_RETRIES 350: ? truncateHeadForPTLRetry(messagesToSummarize, summaryResponse) 351: : null 352: if (!truncated) { 353: logEvent('tengu_compact_failed', { 354: reason: 355: 'prompt_too_long' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 356: preCompactTokenCount, 357: promptCacheSharingEnabled, 358: ptlAttempts, 359: }) 360: throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG) 361: } 362: logEvent('tengu_compact_ptl_retry', { 363: attempt: ptlAttempts, 364: droppedMessages: messagesToSummarize.length - truncated.length, 365: remainingMessages: truncated.length, 366: }) 367: messagesToSummarize = truncated 368: retryCacheSafeParams = { 369: ...retryCacheSafeParams, 370: forkContextMessages: truncated, 371: } 372: } 373: if (!summary) { 374: logForDebugging( 375: `Compact failed: no summary text in response. Response: ${jsonStringify(summaryResponse)}`, 376: { level: 'error' }, 377: ) 378: logEvent('tengu_compact_failed', { 379: reason: 380: 'no_summary' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 381: preCompactTokenCount, 382: promptCacheSharingEnabled, 383: }) 384: throw new Error( 385: `Failed to generate conversation summary - response did not contain valid text content`, 386: ) 387: } else if (startsWithApiErrorPrefix(summary)) { 388: logEvent('tengu_compact_failed', { 389: reason: 390: 'api_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 391: preCompactTokenCount, 392: promptCacheSharingEnabled, 393: }) 394: throw new Error(summary) 395: } 396: const preCompactReadFileState = cacheToObject(context.readFileState) 397: context.readFileState.clear() 398: context.loadedNestedMemoryPaths?.clear() 399: const [fileAttachments, asyncAgentAttachments] = await Promise.all([ 400: createPostCompactFileAttachments( 401: preCompactReadFileState, 402: context, 403: POST_COMPACT_MAX_FILES_TO_RESTORE, 404: ), 405: createAsyncAgentAttachmentsIfNeeded(context), 406: ]) 407: const postCompactFileAttachments: AttachmentMessage[] = [ 408: ...fileAttachments, 409: ...asyncAgentAttachments, 410: ] 411: const planAttachment = createPlanAttachmentIfNeeded(context.agentId) 412: if (planAttachment) { 413: postCompactFileAttachments.push(planAttachment) 414: } 415: const planModeAttachment = await createPlanModeAttachmentIfNeeded(context) 416: if (planModeAttachment) { 417: postCompactFileAttachments.push(planModeAttachment) 418: } 419: const skillAttachment = createSkillAttachmentIfNeeded(context.agentId) 420: if (skillAttachment) { 421: postCompactFileAttachments.push(skillAttachment) 422: } 423: for (const att of getDeferredToolsDeltaAttachment( 424: context.options.tools, 425: context.options.mainLoopModel, 426: [], 427: { callSite: 'compact_full' }, 428: )) { 429: postCompactFileAttachments.push(createAttachmentMessage(att)) 430: } 431: for (const att of getAgentListingDeltaAttachment(context, [])) { 432: postCompactFileAttachments.push(createAttachmentMessage(att)) 433: } 434: for (const att of getMcpInstructionsDeltaAttachment( 435: context.options.mcpClients, 436: context.options.tools, 437: context.options.mainLoopModel, 438: [], 439: )) { 440: postCompactFileAttachments.push(createAttachmentMessage(att)) 441: } 442: context.onCompactProgress?.({ 443: type: 'hooks_start', 444: hookType: 'session_start', 445: }) 446: const hookMessages = await processSessionStartHooks('compact', { 447: model: context.options.mainLoopModel, 448: }) 449: const boundaryMarker = createCompactBoundaryMessage( 450: isAutoCompact ? 'auto' : 'manual', 451: preCompactTokenCount ?? 0, 452: messages.at(-1)?.uuid, 453: ) 454: const preCompactDiscovered = extractDiscoveredToolNames(messages) 455: if (preCompactDiscovered.size > 0) { 456: boundaryMarker.compactMetadata.preCompactDiscoveredTools = [ 457: ...preCompactDiscovered, 458: ].sort() 459: } 460: const transcriptPath = getTranscriptPath() 461: const summaryMessages: UserMessage[] = [ 462: createUserMessage({ 463: content: getCompactUserSummaryMessage( 464: summary, 465: suppressFollowUpQuestions, 466: transcriptPath, 467: ), 468: isCompactSummary: true, 469: isVisibleInTranscriptOnly: true, 470: }), 471: ] 472: const compactionCallTotalTokens = tokenCountFromLastAPIResponse([ 473: summaryResponse, 474: ]) 475: const truePostCompactTokenCount = roughTokenCountEstimationForMessages([ 476: boundaryMarker, 477: ...summaryMessages, 478: ...postCompactFileAttachments, 479: ...hookMessages, 480: ]) 481: const compactionUsage = getTokenUsage(summaryResponse) 482: const querySourceForEvent = 483: recompactionInfo?.querySource ?? context.options.querySource ?? 'unknown' 484: logEvent('tengu_compact', { 485: preCompactTokenCount, 486: postCompactTokenCount: compactionCallTotalTokens, 487: truePostCompactTokenCount, 488: autoCompactThreshold: recompactionInfo?.autoCompactThreshold ?? -1, 489: willRetriggerNextTurn: 490: recompactionInfo !== undefined && 491: truePostCompactTokenCount >= recompactionInfo.autoCompactThreshold, 492: isAutoCompact, 493: querySource: 494: querySourceForEvent as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 495: queryChainId: (context.queryTracking?.chainId ?? 496: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 497: queryDepth: context.queryTracking?.depth ?? -1, 498: isRecompactionInChain: recompactionInfo?.isRecompactionInChain ?? false, 499: turnsSincePreviousCompact: 500: recompactionInfo?.turnsSincePreviousCompact ?? -1, 501: previousCompactTurnId: (recompactionInfo?.previousCompactTurnId ?? 502: '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 503: compactionInputTokens: compactionUsage?.input_tokens, 504: compactionOutputTokens: compactionUsage?.output_tokens, 505: compactionCacheReadTokens: compactionUsage?.cache_read_input_tokens ?? 0, 506: compactionCacheCreationTokens: 507: compactionUsage?.cache_creation_input_tokens ?? 0, 508: compactionTotalTokens: compactionUsage 509: ? compactionUsage.input_tokens + 510: (compactionUsage.cache_creation_input_tokens ?? 0) + 511: (compactionUsage.cache_read_input_tokens ?? 0) + 512: compactionUsage.output_tokens 513: : 0, 514: promptCacheSharingEnabled, 515: // analyzeContext walks every content block (~11ms on a 4.5K-message 516: // session) purely for this telemetry breakdown. Computed here, past 517: // the compaction-API await, so the sync walk doesn't starve the 518: ...(() => { 519: try { 520: return tokenStatsToStatsigMetrics(analyzeContext(messages)) 521: } catch (error) { 522: logError(error as Error) 523: return {} 524: } 525: })(), 526: }) 527: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 528: notifyCompaction( 529: context.options.querySource ?? 'compact', 530: context.agentId, 531: ) 532: } 533: markPostCompaction() 534: reAppendSessionMetadata() 535: if (feature('KAIROS')) { 536: void sessionTranscriptModule?.writeSessionTranscriptSegment(messages) 537: } 538: context.onCompactProgress?.({ 539: type: 'hooks_start', 540: hookType: 'post_compact', 541: }) 542: const postCompactHookResult = await executePostCompactHooks( 543: { 544: trigger: isAutoCompact ? 'auto' : 'manual', 545: compactSummary: summary, 546: }, 547: context.abortController.signal, 548: ) 549: const combinedUserDisplayMessage = [ 550: userDisplayMessage, 551: postCompactHookResult.userDisplayMessage, 552: ] 553: .filter(Boolean) 554: .join('\n') 555: return { 556: boundaryMarker, 557: summaryMessages, 558: attachments: postCompactFileAttachments, 559: hookResults: hookMessages, 560: userDisplayMessage: combinedUserDisplayMessage || undefined, 561: preCompactTokenCount, 562: postCompactTokenCount: compactionCallTotalTokens, 563: truePostCompactTokenCount, 564: compactionUsage, 565: } 566: } catch (error) { 567: if (!isAutoCompact) { 568: addErrorNotificationIfNeeded(error, context) 569: } 570: throw error 571: } finally { 572: context.setStreamMode?.('requesting') 573: context.setResponseLength?.(() => 0) 574: context.onCompactProgress?.({ type: 'compact_end' }) 575: context.setSDKStatus?.(null) 576: } 577: } 578: export async function partialCompactConversation( 579: allMessages: Message[], 580: pivotIndex: number, 581: context: ToolUseContext, 582: cacheSafeParams: CacheSafeParams, 583: userFeedback?: string, 584: direction: PartialCompactDirection = 'from', 585: ): Promise<CompactionResult> { 586: try { 587: const messagesToSummarize = 588: direction === 'up_to' 589: ? allMessages.slice(0, pivotIndex) 590: : allMessages.slice(pivotIndex) 591: const messagesToKeep = 592: direction === 'up_to' 593: ? allMessages 594: .slice(pivotIndex) 595: .filter( 596: m => 597: m.type !== 'progress' && 598: !isCompactBoundaryMessage(m) && 599: !(m.type === 'user' && m.isCompactSummary), 600: ) 601: : allMessages.slice(0, pivotIndex).filter(m => m.type !== 'progress') 602: if (messagesToSummarize.length === 0) { 603: throw new Error( 604: direction === 'up_to' 605: ? 'Nothing to summarize before the selected message.' 606: : 'Nothing to summarize after the selected message.', 607: ) 608: } 609: const preCompactTokenCount = tokenCountWithEstimation(allMessages) 610: context.onCompactProgress?.({ 611: type: 'hooks_start', 612: hookType: 'pre_compact', 613: }) 614: context.setSDKStatus?.('compacting') 615: const hookResult = await executePreCompactHooks( 616: { 617: trigger: 'manual', 618: customInstructions: null, 619: }, 620: context.abortController.signal, 621: ) 622: let customInstructions: string | undefined 623: if (hookResult.newCustomInstructions && userFeedback) { 624: customInstructions = `${hookResult.newCustomInstructions}\n\nUser context: ${userFeedback}` 625: } else if (hookResult.newCustomInstructions) { 626: customInstructions = hookResult.newCustomInstructions 627: } else if (userFeedback) { 628: customInstructions = `User context: ${userFeedback}` 629: } 630: context.setStreamMode?.('requesting') 631: context.setResponseLength?.(() => 0) 632: context.onCompactProgress?.({ type: 'compact_start' }) 633: const compactPrompt = getPartialCompactPrompt(customInstructions, direction) 634: const summaryRequest = createUserMessage({ 635: content: compactPrompt, 636: }) 637: const failureMetadata = { 638: preCompactTokenCount, 639: direction: 640: direction as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 641: messagesSummarized: messagesToSummarize.length, 642: } 643: let apiMessages = direction === 'up_to' ? messagesToSummarize : allMessages 644: let retryCacheSafeParams = 645: direction === 'up_to' 646: ? { ...cacheSafeParams, forkContextMessages: messagesToSummarize } 647: : cacheSafeParams 648: let summaryResponse: AssistantMessage 649: let summary: string | null 650: let ptlAttempts = 0 651: for (;;) { 652: summaryResponse = await streamCompactSummary({ 653: messages: apiMessages, 654: summaryRequest, 655: appState: context.getAppState(), 656: context, 657: preCompactTokenCount, 658: cacheSafeParams: retryCacheSafeParams, 659: }) 660: summary = getAssistantMessageText(summaryResponse) 661: if (!summary?.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) break 662: ptlAttempts++ 663: const truncated = 664: ptlAttempts <= MAX_PTL_RETRIES 665: ? truncateHeadForPTLRetry(apiMessages, summaryResponse) 666: : null 667: if (!truncated) { 668: logEvent('tengu_partial_compact_failed', { 669: reason: 670: 'prompt_too_long' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 671: ...failureMetadata, 672: ptlAttempts, 673: }) 674: throw new Error(ERROR_MESSAGE_PROMPT_TOO_LONG) 675: } 676: logEvent('tengu_compact_ptl_retry', { 677: attempt: ptlAttempts, 678: droppedMessages: apiMessages.length - truncated.length, 679: remainingMessages: truncated.length, 680: path: 'partial' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 681: }) 682: apiMessages = truncated 683: retryCacheSafeParams = { 684: ...retryCacheSafeParams, 685: forkContextMessages: truncated, 686: } 687: } 688: if (!summary) { 689: logEvent('tengu_partial_compact_failed', { 690: reason: 691: 'no_summary' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 692: ...failureMetadata, 693: }) 694: throw new Error( 695: 'Failed to generate conversation summary - response did not contain valid text content', 696: ) 697: } else if (startsWithApiErrorPrefix(summary)) { 698: logEvent('tengu_partial_compact_failed', { 699: reason: 700: 'api_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 701: ...failureMetadata, 702: }) 703: throw new Error(summary) 704: } 705: const preCompactReadFileState = cacheToObject(context.readFileState) 706: context.readFileState.clear() 707: context.loadedNestedMemoryPaths?.clear() 708: const [fileAttachments, asyncAgentAttachments] = await Promise.all([ 709: createPostCompactFileAttachments( 710: preCompactReadFileState, 711: context, 712: POST_COMPACT_MAX_FILES_TO_RESTORE, 713: messagesToKeep, 714: ), 715: createAsyncAgentAttachmentsIfNeeded(context), 716: ]) 717: const postCompactFileAttachments: AttachmentMessage[] = [ 718: ...fileAttachments, 719: ...asyncAgentAttachments, 720: ] 721: const planAttachment = createPlanAttachmentIfNeeded(context.agentId) 722: if (planAttachment) { 723: postCompactFileAttachments.push(planAttachment) 724: } 725: const planModeAttachment = await createPlanModeAttachmentIfNeeded(context) 726: if (planModeAttachment) { 727: postCompactFileAttachments.push(planModeAttachment) 728: } 729: const skillAttachment = createSkillAttachmentIfNeeded(context.agentId) 730: if (skillAttachment) { 731: postCompactFileAttachments.push(skillAttachment) 732: } 733: for (const att of getDeferredToolsDeltaAttachment( 734: context.options.tools, 735: context.options.mainLoopModel, 736: messagesToKeep, 737: { callSite: 'compact_partial' }, 738: )) { 739: postCompactFileAttachments.push(createAttachmentMessage(att)) 740: } 741: for (const att of getAgentListingDeltaAttachment(context, messagesToKeep)) { 742: postCompactFileAttachments.push(createAttachmentMessage(att)) 743: } 744: for (const att of getMcpInstructionsDeltaAttachment( 745: context.options.mcpClients, 746: context.options.tools, 747: context.options.mainLoopModel, 748: messagesToKeep, 749: )) { 750: postCompactFileAttachments.push(createAttachmentMessage(att)) 751: } 752: context.onCompactProgress?.({ 753: type: 'hooks_start', 754: hookType: 'session_start', 755: }) 756: const hookMessages = await processSessionStartHooks('compact', { 757: model: context.options.mainLoopModel, 758: }) 759: const postCompactTokenCount = tokenCountFromLastAPIResponse([ 760: summaryResponse, 761: ]) 762: const compactionUsage = getTokenUsage(summaryResponse) 763: logEvent('tengu_partial_compact', { 764: preCompactTokenCount, 765: postCompactTokenCount, 766: messagesKept: messagesToKeep.length, 767: messagesSummarized: messagesToSummarize.length, 768: direction: 769: direction as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 770: hasUserFeedback: !!userFeedback, 771: trigger: 772: 'message_selector' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 773: compactionInputTokens: compactionUsage?.input_tokens, 774: compactionOutputTokens: compactionUsage?.output_tokens, 775: compactionCacheReadTokens: compactionUsage?.cache_read_input_tokens ?? 0, 776: compactionCacheCreationTokens: 777: compactionUsage?.cache_creation_input_tokens ?? 0, 778: }) 779: const lastPreCompactUuid = 780: direction === 'up_to' 781: ? allMessages.slice(0, pivotIndex).findLast(m => m.type !== 'progress') 782: ?.uuid 783: : messagesToKeep.at(-1)?.uuid 784: const boundaryMarker = createCompactBoundaryMessage( 785: 'manual', 786: preCompactTokenCount ?? 0, 787: lastPreCompactUuid, 788: userFeedback, 789: messagesToSummarize.length, 790: ) 791: const preCompactDiscovered = extractDiscoveredToolNames(allMessages) 792: if (preCompactDiscovered.size > 0) { 793: boundaryMarker.compactMetadata.preCompactDiscoveredTools = [ 794: ...preCompactDiscovered, 795: ].sort() 796: } 797: const transcriptPath = getTranscriptPath() 798: const summaryMessages: UserMessage[] = [ 799: createUserMessage({ 800: content: getCompactUserSummaryMessage(summary, false, transcriptPath), 801: isCompactSummary: true, 802: ...(messagesToKeep.length > 0 803: ? { 804: summarizeMetadata: { 805: messagesSummarized: messagesToSummarize.length, 806: userContext: userFeedback, 807: direction, 808: }, 809: } 810: : { isVisibleInTranscriptOnly: true as const }), 811: }), 812: ] 813: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 814: notifyCompaction( 815: context.options.querySource ?? 'compact', 816: context.agentId, 817: ) 818: } 819: markPostCompaction() 820: reAppendSessionMetadata() 821: if (feature('KAIROS')) { 822: void sessionTranscriptModule?.writeSessionTranscriptSegment( 823: messagesToSummarize, 824: ) 825: } 826: context.onCompactProgress?.({ 827: type: 'hooks_start', 828: hookType: 'post_compact', 829: }) 830: const postCompactHookResult = await executePostCompactHooks( 831: { 832: trigger: 'manual', 833: compactSummary: summary, 834: }, 835: context.abortController.signal, 836: ) 837: const anchorUuid = 838: direction === 'up_to' 839: ? (summaryMessages.at(-1)?.uuid ?? boundaryMarker.uuid) 840: : boundaryMarker.uuid 841: return { 842: boundaryMarker: annotateBoundaryWithPreservedSegment( 843: boundaryMarker, 844: anchorUuid, 845: messagesToKeep, 846: ), 847: summaryMessages, 848: messagesToKeep, 849: attachments: postCompactFileAttachments, 850: hookResults: hookMessages, 851: userDisplayMessage: postCompactHookResult.userDisplayMessage, 852: preCompactTokenCount, 853: postCompactTokenCount, 854: compactionUsage, 855: } 856: } catch (error) { 857: addErrorNotificationIfNeeded(error, context) 858: throw error 859: } finally { 860: context.setStreamMode?.('requesting') 861: context.setResponseLength?.(() => 0) 862: context.onCompactProgress?.({ type: 'compact_end' }) 863: context.setSDKStatus?.(null) 864: } 865: } 866: function addErrorNotificationIfNeeded( 867: error: unknown, 868: context: Pick<ToolUseContext, 'addNotification'>, 869: ) { 870: if ( 871: !hasExactErrorMessage(error, ERROR_MESSAGE_USER_ABORT) && 872: !hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES) 873: ) { 874: context.addNotification?.({ 875: key: 'error-compacting-conversation', 876: text: 'Error compacting conversation', 877: priority: 'immediate', 878: color: 'error', 879: }) 880: } 881: } 882: export function createCompactCanUseTool(): CanUseToolFn { 883: return async () => ({ 884: behavior: 'deny' as const, 885: message: 'Tool use is not allowed during compaction', 886: decisionReason: { 887: type: 'other' as const, 888: reason: 'compaction agent should only produce text summary', 889: }, 890: }) 891: } 892: async function streamCompactSummary({ 893: messages, 894: summaryRequest, 895: appState, 896: context, 897: preCompactTokenCount, 898: cacheSafeParams, 899: }: { 900: messages: Message[] 901: summaryRequest: UserMessage 902: appState: Awaited<ReturnType<ToolUseContext['getAppState']>> 903: context: ToolUseContext 904: preCompactTokenCount: number 905: cacheSafeParams: CacheSafeParams 906: }): Promise<AssistantMessage> { 907: const promptCacheSharingEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 908: 'tengu_compact_cache_prefix', 909: true, 910: ) 911: const activityInterval = isSessionActivityTrackingActive() 912: ? setInterval( 913: (statusSetter?: (status: 'compacting' | null) => void) => { 914: sendSessionActivitySignal() 915: statusSetter?.('compacting') 916: }, 917: 30_000, 918: context.setSDKStatus, 919: ) 920: : undefined 921: try { 922: if (promptCacheSharingEnabled) { 923: try { 924: const result = await runForkedAgent({ 925: promptMessages: [summaryRequest], 926: cacheSafeParams, 927: canUseTool: createCompactCanUseTool(), 928: querySource: 'compact', 929: forkLabel: 'compact', 930: maxTurns: 1, 931: skipCacheWrite: true, 932: overrides: { abortController: context.abortController }, 933: }) 934: const assistantMsg = getLastAssistantMessage(result.messages) 935: const assistantText = assistantMsg 936: ? getAssistantMessageText(assistantMsg) 937: : null 938: if (assistantMsg && assistantText && !assistantMsg.isApiErrorMessage) { 939: if (!assistantText.startsWith(PROMPT_TOO_LONG_ERROR_MESSAGE)) { 940: logEvent('tengu_compact_cache_sharing_success', { 941: preCompactTokenCount, 942: outputTokens: result.totalUsage.output_tokens, 943: cacheReadInputTokens: result.totalUsage.cache_read_input_tokens, 944: cacheCreationInputTokens: 945: result.totalUsage.cache_creation_input_tokens, 946: cacheHitRate: 947: result.totalUsage.cache_read_input_tokens > 0 948: ? result.totalUsage.cache_read_input_tokens / 949: (result.totalUsage.cache_read_input_tokens + 950: result.totalUsage.cache_creation_input_tokens + 951: result.totalUsage.input_tokens) 952: : 0, 953: }) 954: } 955: return assistantMsg 956: } 957: logForDebugging( 958: `Compact cache sharing: no text in response, falling back. Response: ${jsonStringify(assistantMsg)}`, 959: { level: 'warn' }, 960: ) 961: logEvent('tengu_compact_cache_sharing_fallback', { 962: reason: 963: 'no_text_response' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 964: preCompactTokenCount, 965: }) 966: } catch (error) { 967: logError(error) 968: logEvent('tengu_compact_cache_sharing_fallback', { 969: reason: 970: 'error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 971: preCompactTokenCount, 972: }) 973: } 974: } 975: const retryEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 976: 'tengu_compact_streaming_retry', 977: false, 978: ) 979: const maxAttempts = retryEnabled ? MAX_COMPACT_STREAMING_RETRIES : 1 980: for (let attempt = 1; attempt <= maxAttempts; attempt++) { 981: let hasStartedStreaming = false 982: let response: AssistantMessage | undefined 983: context.setResponseLength?.(() => 0) 984: const useToolSearch = await isToolSearchEnabled( 985: context.options.mainLoopModel, 986: context.options.tools, 987: async () => appState.toolPermissionContext, 988: context.options.agentDefinitions.activeAgents, 989: 'compact', 990: ) 991: const tools: Tool[] = useToolSearch 992: ? uniqBy( 993: [ 994: FileReadTool, 995: ToolSearchTool, 996: ...context.options.tools.filter(t => t.isMcp), 997: ], 998: 'name', 999: ) 1000: : [FileReadTool] 1001: const streamingGen = queryModelWithStreaming({ 1002: messages: normalizeMessagesForAPI( 1003: stripImagesFromMessages( 1004: stripReinjectedAttachments([ 1005: ...getMessagesAfterCompactBoundary(messages), 1006: summaryRequest, 1007: ]), 1008: ), 1009: context.options.tools, 1010: ), 1011: systemPrompt: asSystemPrompt([ 1012: 'You are a helpful AI assistant tasked with summarizing conversations.', 1013: ]), 1014: thinkingConfig: { type: 'disabled' as const }, 1015: tools, 1016: signal: context.abortController.signal, 1017: options: { 1018: async getToolPermissionContext() { 1019: const appState = context.getAppState() 1020: return appState.toolPermissionContext 1021: }, 1022: model: context.options.mainLoopModel, 1023: toolChoice: undefined, 1024: isNonInteractiveSession: context.options.isNonInteractiveSession, 1025: hasAppendSystemPrompt: !!context.options.appendSystemPrompt, 1026: maxOutputTokensOverride: Math.min( 1027: COMPACT_MAX_OUTPUT_TOKENS, 1028: getMaxOutputTokensForModel(context.options.mainLoopModel), 1029: ), 1030: querySource: 'compact', 1031: agents: context.options.agentDefinitions.activeAgents, 1032: mcpTools: [], 1033: effortValue: appState.effortValue, 1034: }, 1035: }) 1036: const streamIter = streamingGen[Symbol.asyncIterator]() 1037: let next = await streamIter.next() 1038: while (!next.done) { 1039: const event = next.value 1040: if ( 1041: !hasStartedStreaming && 1042: event.type === 'stream_event' && 1043: event.event.type === 'content_block_start' && 1044: event.event.content_block.type === 'text' 1045: ) { 1046: hasStartedStreaming = true 1047: context.setStreamMode?.('responding') 1048: } 1049: if ( 1050: event.type === 'stream_event' && 1051: event.event.type === 'content_block_delta' && 1052: event.event.delta.type === 'text_delta' 1053: ) { 1054: const charactersStreamed = event.event.delta.text.length 1055: context.setResponseLength?.(length => length + charactersStreamed) 1056: } 1057: if (event.type === 'assistant') { 1058: response = event 1059: } 1060: next = await streamIter.next() 1061: } 1062: if (response) { 1063: return response 1064: } 1065: if (attempt < maxAttempts) { 1066: logEvent('tengu_compact_streaming_retry', { 1067: attempt, 1068: preCompactTokenCount, 1069: hasStartedStreaming, 1070: }) 1071: await sleep(getRetryDelay(attempt), context.abortController.signal, { 1072: abortError: () => new APIUserAbortError(), 1073: }) 1074: continue 1075: } 1076: logForDebugging( 1077: `Compact streaming failed after ${attempt} attempts. hasStartedStreaming=${hasStartedStreaming}`, 1078: { level: 'error' }, 1079: ) 1080: logEvent('tengu_compact_failed', { 1081: reason: 1082: 'no_streaming_response' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1083: preCompactTokenCount, 1084: hasStartedStreaming, 1085: retryEnabled, 1086: attempts: attempt, 1087: promptCacheSharingEnabled, 1088: }) 1089: throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE) 1090: } 1091: throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE) 1092: } finally { 1093: clearInterval(activityInterval) 1094: } 1095: } 1096: export async function createPostCompactFileAttachments( 1097: readFileState: Record<string, { content: string; timestamp: number }>, 1098: toolUseContext: ToolUseContext, 1099: maxFiles: number, 1100: preservedMessages: Message[] = [], 1101: ): Promise<AttachmentMessage[]> { 1102: const preservedReadPaths = collectReadToolFilePaths(preservedMessages) 1103: const recentFiles = Object.entries(readFileState) 1104: .map(([filename, state]) => ({ filename, ...state })) 1105: .filter( 1106: file => 1107: !shouldExcludeFromPostCompactRestore( 1108: file.filename, 1109: toolUseContext.agentId, 1110: ) && !preservedReadPaths.has(expandPath(file.filename)), 1111: ) 1112: .sort((a, b) => b.timestamp - a.timestamp) 1113: .slice(0, maxFiles) 1114: const results = await Promise.all( 1115: recentFiles.map(async file => { 1116: const attachment = await generateFileAttachment( 1117: file.filename, 1118: { 1119: ...toolUseContext, 1120: fileReadingLimits: { 1121: maxTokens: POST_COMPACT_MAX_TOKENS_PER_FILE, 1122: }, 1123: }, 1124: 'tengu_post_compact_file_restore_success', 1125: 'tengu_post_compact_file_restore_error', 1126: 'compact', 1127: ) 1128: return attachment ? createAttachmentMessage(attachment) : null 1129: }), 1130: ) 1131: let usedTokens = 0 1132: return results.filter((result): result is AttachmentMessage => { 1133: if (result === null) { 1134: return false 1135: } 1136: const attachmentTokens = roughTokenCountEstimation(jsonStringify(result)) 1137: if (usedTokens + attachmentTokens <= POST_COMPACT_TOKEN_BUDGET) { 1138: usedTokens += attachmentTokens 1139: return true 1140: } 1141: return false 1142: }) 1143: } 1144: export function createPlanAttachmentIfNeeded( 1145: agentId?: AgentId, 1146: ): AttachmentMessage | null { 1147: const planContent = getPlan(agentId) 1148: if (!planContent) { 1149: return null 1150: } 1151: const planFilePath = getPlanFilePath(agentId) 1152: return createAttachmentMessage({ 1153: type: 'plan_file_reference', 1154: planFilePath, 1155: planContent, 1156: }) 1157: } 1158: export function createSkillAttachmentIfNeeded( 1159: agentId?: string, 1160: ): AttachmentMessage | null { 1161: const invokedSkills = getInvokedSkillsForAgent(agentId) 1162: if (invokedSkills.size === 0) { 1163: return null 1164: } 1165: let usedTokens = 0 1166: const skills = Array.from(invokedSkills.values()) 1167: .sort((a, b) => b.invokedAt - a.invokedAt) 1168: .map(skill => ({ 1169: name: skill.skillName, 1170: path: skill.skillPath, 1171: content: truncateToTokens( 1172: skill.content, 1173: POST_COMPACT_MAX_TOKENS_PER_SKILL, 1174: ), 1175: })) 1176: .filter(skill => { 1177: const tokens = roughTokenCountEstimation(skill.content) 1178: if (usedTokens + tokens > POST_COMPACT_SKILLS_TOKEN_BUDGET) { 1179: return false 1180: } 1181: usedTokens += tokens 1182: return true 1183: }) 1184: if (skills.length === 0) { 1185: return null 1186: } 1187: return createAttachmentMessage({ 1188: type: 'invoked_skills', 1189: skills, 1190: }) 1191: } 1192: export async function createPlanModeAttachmentIfNeeded( 1193: context: ToolUseContext, 1194: ): Promise<AttachmentMessage | null> { 1195: const appState = context.getAppState() 1196: if (appState.toolPermissionContext.mode !== 'plan') { 1197: return null 1198: } 1199: const planFilePath = getPlanFilePath(context.agentId) 1200: const planExists = getPlan(context.agentId) !== null 1201: return createAttachmentMessage({ 1202: type: 'plan_mode', 1203: reminderType: 'full', 1204: isSubAgent: !!context.agentId, 1205: planFilePath, 1206: planExists, 1207: }) 1208: } 1209: export async function createAsyncAgentAttachmentsIfNeeded( 1210: context: ToolUseContext, 1211: ): Promise<AttachmentMessage[]> { 1212: const appState = context.getAppState() 1213: const asyncAgents = Object.values(appState.tasks).filter( 1214: (task): task is LocalAgentTaskState => task.type === 'local_agent', 1215: ) 1216: return asyncAgents.flatMap(agent => { 1217: if ( 1218: agent.retrieved || 1219: agent.status === 'pending' || 1220: agent.agentId === context.agentId 1221: ) { 1222: return [] 1223: } 1224: return [ 1225: createAttachmentMessage({ 1226: type: 'task_status', 1227: taskId: agent.agentId, 1228: taskType: 'local_agent', 1229: description: agent.description, 1230: status: agent.status, 1231: deltaSummary: 1232: agent.status === 'running' 1233: ? (agent.progress?.summary ?? null) 1234: : (agent.error ?? null), 1235: outputFilePath: getTaskOutputPath(agent.agentId), 1236: }), 1237: ] 1238: }) 1239: } 1240: function collectReadToolFilePaths(messages: Message[]): Set<string> { 1241: const stubIds = new Set<string>() 1242: for (const message of messages) { 1243: if (message.type !== 'user' || !Array.isArray(message.message.content)) { 1244: continue 1245: } 1246: for (const block of message.message.content) { 1247: if ( 1248: block.type === 'tool_result' && 1249: typeof block.content === 'string' && 1250: block.content.startsWith(FILE_UNCHANGED_STUB) 1251: ) { 1252: stubIds.add(block.tool_use_id) 1253: } 1254: } 1255: } 1256: const paths = new Set<string>() 1257: for (const message of messages) { 1258: if ( 1259: message.type !== 'assistant' || 1260: !Array.isArray(message.message.content) 1261: ) { 1262: continue 1263: } 1264: for (const block of message.message.content) { 1265: if ( 1266: block.type !== 'tool_use' || 1267: block.name !== FILE_READ_TOOL_NAME || 1268: stubIds.has(block.id) 1269: ) { 1270: continue 1271: } 1272: const input = block.input 1273: if ( 1274: input && 1275: typeof input === 'object' && 1276: 'file_path' in input && 1277: typeof input.file_path === 'string' 1278: ) { 1279: paths.add(expandPath(input.file_path)) 1280: } 1281: } 1282: } 1283: return paths 1284: } 1285: const SKILL_TRUNCATION_MARKER = 1286: '\n\n[... skill content truncated for compaction; use Read on the skill path if you need the full text]' 1287: function truncateToTokens(content: string, maxTokens: number): string { 1288: if (roughTokenCountEstimation(content) <= maxTokens) { 1289: return content 1290: } 1291: const charBudget = maxTokens * 4 - SKILL_TRUNCATION_MARKER.length 1292: return content.slice(0, charBudget) + SKILL_TRUNCATION_MARKER 1293: } 1294: function shouldExcludeFromPostCompactRestore( 1295: filename: string, 1296: agentId?: AgentId, 1297: ): boolean { 1298: const normalizedFilename = expandPath(filename) 1299: try { 1300: const planFilePath = expandPath(getPlanFilePath(agentId)) 1301: if (normalizedFilename === planFilePath) { 1302: return true 1303: } 1304: } catch { 1305: } 1306: try { 1307: const normalizedMemoryPaths = new Set( 1308: MEMORY_TYPE_VALUES.map(type => expandPath(getMemoryPath(type))), 1309: ) 1310: if (normalizedMemoryPaths.has(normalizedFilename)) { 1311: return true 1312: } 1313: } catch { 1314: } 1315: return false 1316: }

File: src/services/compact/compactWarningHook.ts

typescript 1: import { useSyncExternalStore } from 'react' 2: import { compactWarningStore } from './compactWarningState.js' 3: export function useCompactWarningSuppression(): boolean { 4: return useSyncExternalStore( 5: compactWarningStore.subscribe, 6: compactWarningStore.getState, 7: ) 8: }

File: src/services/compact/compactWarningState.ts

typescript 1: import { createStore } from '../../state/store.js' 2: export const compactWarningStore = createStore<boolean>(false) 3: export function suppressCompactWarning(): void { 4: compactWarningStore.setState(() => true) 5: } 6: export function clearCompactWarningSuppression(): void { 7: compactWarningStore.setState(() => false) 8: }

File: src/services/compact/grouping.ts

typescript 1: import type { Message } from '../../types/message.js' 2: export function groupMessagesByApiRound(messages: Message[]): Message[][] { 3: const groups: Message[][] = [] 4: let current: Message[] = [] 5: let lastAssistantId: string | undefined 6: for (const msg of messages) { 7: if ( 8: msg.type === 'assistant' && 9: msg.message.id !== lastAssistantId && 10: current.length > 0 11: ) { 12: groups.push(current) 13: current = [msg] 14: } else { 15: current.push(msg) 16: } 17: if (msg.type === 'assistant') { 18: lastAssistantId = msg.message.id 19: } 20: } 21: if (current.length > 0) { 22: groups.push(current) 23: } 24: return groups 25: }

File: src/services/compact/microCompact.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' 3: import type { QuerySource } from '../../constants/querySource.js' 4: import type { ToolUseContext } from '../../Tool.js' 5: import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 6: import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' 7: import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' 8: import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js' 9: import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js' 10: import { WEB_FETCH_TOOL_NAME } from '../../tools/WebFetchTool/prompt.js' 11: import { WEB_SEARCH_TOOL_NAME } from '../../tools/WebSearchTool/prompt.js' 12: import type { Message } from '../../types/message.js' 13: import { logForDebugging } from '../../utils/debug.js' 14: import { getMainLoopModel } from '../../utils/model/model.js' 15: import { SHELL_TOOL_NAMES } from '../../utils/shell/shellToolUtils.js' 16: import { jsonStringify } from '../../utils/slowOperations.js' 17: import { 18: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 19: logEvent, 20: } from '../analytics/index.js' 21: import { notifyCacheDeletion } from '../api/promptCacheBreakDetection.js' 22: import { roughTokenCountEstimation } from '../tokenEstimation.js' 23: import { 24: clearCompactWarningSuppression, 25: suppressCompactWarning, 26: } from './compactWarningState.js' 27: import { 28: getTimeBasedMCConfig, 29: type TimeBasedMCConfig, 30: } from './timeBasedMCConfig.js' 31: export const TIME_BASED_MC_CLEARED_MESSAGE = '[Old tool result content cleared]' 32: const IMAGE_MAX_TOKEN_SIZE = 2000 33: const COMPACTABLE_TOOLS = new Set<string>([ 34: FILE_READ_TOOL_NAME, 35: ...SHELL_TOOL_NAMES, 36: GREP_TOOL_NAME, 37: GLOB_TOOL_NAME, 38: WEB_SEARCH_TOOL_NAME, 39: WEB_FETCH_TOOL_NAME, 40: FILE_EDIT_TOOL_NAME, 41: FILE_WRITE_TOOL_NAME, 42: ]) 43: let cachedMCModule: typeof import('./cachedMicrocompact.js') | null = null 44: let cachedMCState: import('./cachedMicrocompact.js').CachedMCState | null = null 45: let pendingCacheEdits: 46: | import('./cachedMicrocompact.js').CacheEditsBlock 47: | null = null 48: async function getCachedMCModule(): Promise< 49: typeof import('./cachedMicrocompact.js') 50: > { 51: if (!cachedMCModule) { 52: cachedMCModule = await import('./cachedMicrocompact.js') 53: } 54: return cachedMCModule 55: } 56: function ensureCachedMCState(): import('./cachedMicrocompact.js').CachedMCState { 57: if (!cachedMCState && cachedMCModule) { 58: cachedMCState = cachedMCModule.createCachedMCState() 59: } 60: if (!cachedMCState) { 61: throw new Error( 62: 'cachedMCState not initialized — getCachedMCModule() must be called first', 63: ) 64: } 65: return cachedMCState 66: } 67: export function consumePendingCacheEdits(): 68: | import('./cachedMicrocompact.js').CacheEditsBlock 69: | null { 70: const edits = pendingCacheEdits 71: pendingCacheEdits = null 72: return edits 73: } 74: export function getPinnedCacheEdits(): import('./cachedMicrocompact.js').PinnedCacheEdits[] { 75: if (!cachedMCState) { 76: return [] 77: } 78: return cachedMCState.pinnedEdits 79: } 80: export function pinCacheEdits( 81: userMessageIndex: number, 82: block: import('./cachedMicrocompact.js').CacheEditsBlock, 83: ): void { 84: if (cachedMCState) { 85: cachedMCState.pinnedEdits.push({ userMessageIndex, block }) 86: } 87: } 88: export function markToolsSentToAPIState(): void { 89: if (cachedMCState && cachedMCModule) { 90: cachedMCModule.markToolsSentToAPI(cachedMCState) 91: } 92: } 93: export function resetMicrocompactState(): void { 94: if (cachedMCState && cachedMCModule) { 95: cachedMCModule.resetCachedMCState(cachedMCState) 96: } 97: pendingCacheEdits = null 98: } 99: function calculateToolResultTokens(block: ToolResultBlockParam): number { 100: if (!block.content) { 101: return 0 102: } 103: if (typeof block.content === 'string') { 104: return roughTokenCountEstimation(block.content) 105: } 106: return block.content.reduce((sum, item) => { 107: if (item.type === 'text') { 108: return sum + roughTokenCountEstimation(item.text) 109: } else if (item.type === 'image' || item.type === 'document') { 110: return sum + IMAGE_MAX_TOKEN_SIZE 111: } 112: return sum 113: }, 0) 114: } 115: export function estimateMessageTokens(messages: Message[]): number { 116: let totalTokens = 0 117: for (const message of messages) { 118: if (message.type !== 'user' && message.type !== 'assistant') { 119: continue 120: } 121: if (!Array.isArray(message.message.content)) { 122: continue 123: } 124: for (const block of message.message.content) { 125: if (block.type === 'text') { 126: totalTokens += roughTokenCountEstimation(block.text) 127: } else if (block.type === 'tool_result') { 128: totalTokens += calculateToolResultTokens(block) 129: } else if (block.type === 'image' || block.type === 'document') { 130: totalTokens += IMAGE_MAX_TOKEN_SIZE 131: } else if (block.type === 'thinking') { 132: totalTokens += roughTokenCountEstimation(block.thinking) 133: } else if (block.type === 'redacted_thinking') { 134: totalTokens += roughTokenCountEstimation(block.data) 135: } else if (block.type === 'tool_use') { 136: totalTokens += roughTokenCountEstimation( 137: block.name + jsonStringify(block.input ?? {}), 138: ) 139: } else { 140: totalTokens += roughTokenCountEstimation(jsonStringify(block)) 141: } 142: } 143: } 144: return Math.ceil(totalTokens * (4 / 3)) 145: } 146: export type PendingCacheEdits = { 147: trigger: 'auto' 148: deletedToolIds: string[] 149: baselineCacheDeletedTokens: number 150: } 151: export type MicrocompactResult = { 152: messages: Message[] 153: compactionInfo?: { 154: pendingCacheEdits?: PendingCacheEdits 155: } 156: } 157: function collectCompactableToolIds(messages: Message[]): string[] { 158: const ids: string[] = [] 159: for (const message of messages) { 160: if ( 161: message.type === 'assistant' && 162: Array.isArray(message.message.content) 163: ) { 164: for (const block of message.message.content) { 165: if (block.type === 'tool_use' && COMPACTABLE_TOOLS.has(block.name)) { 166: ids.push(block.id) 167: } 168: } 169: } 170: } 171: return ids 172: } 173: function isMainThreadSource(querySource: QuerySource | undefined): boolean { 174: return !querySource || querySource.startsWith('repl_main_thread') 175: } 176: export async function microcompactMessages( 177: messages: Message[], 178: toolUseContext?: ToolUseContext, 179: querySource?: QuerySource, 180: ): Promise<MicrocompactResult> { 181: clearCompactWarningSuppression() 182: const timeBasedResult = maybeTimeBasedMicrocompact(messages, querySource) 183: if (timeBasedResult) { 184: return timeBasedResult 185: } 186: if (feature('CACHED_MICROCOMPACT')) { 187: const mod = await getCachedMCModule() 188: const model = toolUseContext?.options.mainLoopModel ?? getMainLoopModel() 189: if ( 190: mod.isCachedMicrocompactEnabled() && 191: mod.isModelSupportedForCacheEditing(model) && 192: isMainThreadSource(querySource) 193: ) { 194: return await cachedMicrocompactPath(messages, querySource) 195: } 196: } 197: return { messages } 198: } 199: async function cachedMicrocompactPath( 200: messages: Message[], 201: querySource: QuerySource | undefined, 202: ): Promise<MicrocompactResult> { 203: const mod = await getCachedMCModule() 204: const state = ensureCachedMCState() 205: const config = mod.getCachedMCConfig() 206: const compactableToolIds = new Set(collectCompactableToolIds(messages)) 207: for (const message of messages) { 208: if (message.type === 'user' && Array.isArray(message.message.content)) { 209: const groupIds: string[] = [] 210: for (const block of message.message.content) { 211: if ( 212: block.type === 'tool_result' && 213: compactableToolIds.has(block.tool_use_id) && 214: !state.registeredTools.has(block.tool_use_id) 215: ) { 216: mod.registerToolResult(state, block.tool_use_id) 217: groupIds.push(block.tool_use_id) 218: } 219: } 220: mod.registerToolMessage(state, groupIds) 221: } 222: } 223: const toolsToDelete = mod.getToolResultsToDelete(state) 224: if (toolsToDelete.length > 0) { 225: const cacheEdits = mod.createCacheEditsBlock(state, toolsToDelete) 226: if (cacheEdits) { 227: pendingCacheEdits = cacheEdits 228: } 229: logForDebugging( 230: `Cached MC deleting ${toolsToDelete.length} tool(s): ${toolsToDelete.join(', ')}`, 231: ) 232: logEvent('tengu_cached_microcompact', { 233: toolsDeleted: toolsToDelete.length, 234: deletedToolIds: toolsToDelete.join( 235: ',', 236: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 237: activeToolCount: state.toolOrder.length - state.deletedRefs.size, 238: triggerType: 239: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 240: threshold: config.triggerThreshold, 241: keepRecent: config.keepRecent, 242: }) 243: suppressCompactWarning() 244: if (feature('PROMPT_CACHE_BREAK_DETECTION')) { 245: notifyCacheDeletion(querySource ?? 'repl_main_thread') 246: } 247: const lastAsst = messages.findLast(m => m.type === 'assistant') 248: const baseline = 249: lastAsst?.type === 'assistant' 250: ? (( 251: lastAsst.message.usage as unknown as Record< 252: string, 253: number | undefined 254: > 255: )?.cache_deleted_input_tokens ?? 0) 256: : 0 257: return { 258: messages, 259: compactionInfo: { 260: pendingCacheEdits: { 261: trigger: 'auto', 262: deletedToolIds: toolsToDelete, 263: baselineCacheDeletedTokens: baseline, 264: }, 265: }, 266: } 267: } 268: return { messages } 269: } 270: export function evaluateTimeBasedTrigger( 271: messages: Message[], 272: querySource: QuerySource | undefined, 273: ): { gapMinutes: number; config: TimeBasedMCConfig } | null { 274: const config = getTimeBasedMCConfig() 275: if (!config.enabled || !querySource || !isMainThreadSource(querySource)) { 276: return null 277: } 278: const lastAssistant = messages.findLast(m => m.type === 'assistant') 279: if (!lastAssistant) { 280: return null 281: } 282: const gapMinutes = 283: (Date.now() - new Date(lastAssistant.timestamp).getTime()) / 60_000 284: if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) { 285: return null 286: } 287: return { gapMinutes, config } 288: } 289: function maybeTimeBasedMicrocompact( 290: messages: Message[], 291: querySource: QuerySource | undefined, 292: ): MicrocompactResult | null { 293: const trigger = evaluateTimeBasedTrigger(messages, querySource) 294: if (!trigger) { 295: return null 296: } 297: const { gapMinutes, config } = trigger 298: const compactableIds = collectCompactableToolIds(messages) 299: const keepRecent = Math.max(1, config.keepRecent) 300: const keepSet = new Set(compactableIds.slice(-keepRecent)) 301: const clearSet = new Set(compactableIds.filter(id => !keepSet.has(id))) 302: if (clearSet.size === 0) { 303: return null 304: } 305: let tokensSaved = 0 306: const result: Message[] = messages.map(message => { 307: if (message.type !== 'user' || !Array.isArray(message.message.content)) { 308: return message 309: } 310: let touched = false 311: const newContent = message.message.content.map(block => { 312: if ( 313: block.type === 'tool_result' && 314: clearSet.has(block.tool_use_id) && 315: block.content !== TIME_BASED_MC_CLEARED_MESSAGE 316: ) { 317: tokensSaved += calculateToolResultTokens(block) 318: touched = true 319: return { ...block, content: TIME_BASED_MC_CLEARED_MESSAGE } 320: } 321: return block 322: }) 323: if (!touched) return message 324: return { 325: ...message, 326: message: { ...message.message, content: newContent }, 327: } 328: }) 329: if (tokensSaved === 0) { 330: return null 331: } 332: logEvent('tengu_time_based_microcompact', { 333: gapMinutes: Math.round(gapMinutes), 334: gapThresholdMinutes: config.gapThresholdMinutes, 335: toolsCleared: clearSet.size, 336: toolsKept: keepSet.size, 337: keepRecent: config.keepRecent, 338: tokensSaved, 339: }) 340: logForDebugging( 341: `[TIME-BASED MC] gap ${Math.round(gapMinutes)}min > ${config.gapThresholdMinutes}min, cleared ${clearSet.size} tool results (~${tokensSaved} tokens), kept last ${keepSet.size}`, 342: ) 343: suppressCompactWarning() 344: resetMicrocompactState() 345: if (feature('PROMPT_CACHE_BREAK_DETECTION') && querySource) { 346: notifyCacheDeletion(querySource) 347: } 348: return { messages: result } 349: }

File: src/services/compact/postCompactCleanup.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { QuerySource } from '../../constants/querySource.js' 3: import { clearSystemPromptSections } from '../../constants/systemPromptSections.js' 4: import { getUserContext } from '../../context.js' 5: import { clearSpeculativeChecks } from '../../tools/BashTool/bashPermissions.js' 6: import { clearClassifierApprovals } from '../../utils/classifierApprovals.js' 7: import { resetGetMemoryFilesCache } from '../../utils/claudemd.js' 8: import { clearSessionMessagesCache } from '../../utils/sessionStorage.js' 9: import { clearBetaTracingState } from '../../utils/telemetry/betaSessionTracing.js' 10: import { resetMicrocompactState } from './microCompact.js' 11: export function runPostCompactCleanup(querySource?: QuerySource): void { 12: const isMainThreadCompact = 13: querySource === undefined || 14: querySource.startsWith('repl_main_thread') || 15: querySource === 'sdk' 16: resetMicrocompactState() 17: if (feature('CONTEXT_COLLAPSE')) { 18: if (isMainThreadCompact) { 19: ;( 20: require('../contextCollapse/index.js') as typeof import('../contextCollapse/index.js') 21: ).resetContextCollapse() 22: } 23: } 24: if (isMainThreadCompact) { 25: getUserContext.cache.clear?.() 26: resetGetMemoryFilesCache('compact') 27: } 28: clearSystemPromptSections() 29: clearClassifierApprovals() 30: clearSpeculativeChecks() 31: clearBetaTracingState() 32: if (feature('COMMIT_ATTRIBUTION')) { 33: void import('../../utils/attributionHooks.js').then(m => 34: m.sweepFileContentCache(), 35: ) 36: } 37: clearSessionMessagesCache() 38: }

File: src/services/compact/prompt.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { PartialCompactDirection } from '../../types/message.js' 3: const proactiveModule = 4: feature('PROACTIVE') || feature('KAIROS') 5: ? (require('../../proactive/index.js') as typeof import('../../proactive/index.js')) 6: : null 7: const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools. 8: - Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool. 9: - You already have all the context you need in the conversation above. 10: - Tool calls will be REJECTED and will waste your only turn — you will fail the task. 11: - Your entire response must be plain text: an <analysis> block followed by a <summary> block. 12: ` 13: const DETAILED_ANALYSIS_INSTRUCTION_BASE = `Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: 14: 1. Chronologically analyze each message and section of the conversation. For each section thoroughly identify: 15: - The user's explicit requests and intents 16: - Your approach to addressing the user's requests 17: - Key decisions, technical concepts and code patterns 18: - Specific details like: 19: - file names 20: - full code snippets 21: - function signatures 22: - file edits 23: - Errors that you ran into and how you fixed them 24: - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. 25: 2. Double-check for technical accuracy and completeness, addressing each required element thoroughly.` 26: const DETAILED_ANALYSIS_INSTRUCTION_PARTIAL = `Before providing your final summary, wrap your analysis in <analysis> tags to organize your thoughts and ensure you've covered all necessary points. In your analysis process: 27: 1. Analyze the recent messages chronologically. For each section thoroughly identify: 28: - The user's explicit requests and intents 29: - Your approach to addressing the user's requests 30: - Key decisions, technical concepts and code patterns 31: - Specific details like: 32: - file names 33: - full code snippets 34: - function signatures 35: - file edits 36: - Errors that you ran into and how you fixed them 37: - Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. 38: 2. Double-check for technical accuracy and completeness, addressing each required element thoroughly.` 39: const BASE_COMPACT_PROMPT = `Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. 40: This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing development work without losing context. 41: ${DETAILED_ANALYSIS_INSTRUCTION_BASE} 42: Your summary should include the following sections: 43: 1. Primary Request and Intent: Capture all of the user's explicit requests and intents in detail 44: 2. Key Technical Concepts: List all important technical concepts, technologies, and frameworks discussed. 45: 3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Pay special attention to the most recent messages and include full code snippets where applicable and include a summary of why this file read or edit is important. 46: 4. Errors and fixes: List all errors that you ran into, and how you fixed them. Pay special attention to specific user feedback that you received, especially if the user told you to do something differently. 47: 5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. 48: 6. All user messages: List ALL user messages that are not tool results. These are critical for understanding the users' feedback and changing intent. 49: 7. Pending Tasks: Outline any pending tasks that you have explicitly been asked to work on. 50: 8. Current Work: Describe in detail precisely what was being worked on immediately before this summary request, paying special attention to the most recent messages from both user and assistant. Include file names and code snippets where applicable. 51: 9. Optional Next Step: List the next step that you will take that is related to the most recent work you were doing. IMPORTANT: ensure that this step is DIRECTLY in line with the user's most recent explicit requests, and the task you were working on immediately before this summary request. If your last task was concluded, then only list next steps if they are explicitly in line with the users request. Do not start on tangential requests or really old requests that were already completed without confirming with the user first. 52: If there is a next step, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no drift in task interpretation. 53: Here's an example of how your output should be structured: 54: <example> 55: <analysis> 56: [Your thought process, ensuring all points are covered thoroughly and accurately] 57: </analysis> 58: <summary> 59: 1. Primary Request and Intent: 60: [Detailed description] 61: 2. Key Technical Concepts: 62: - [Concept 1] 63: - [Concept 2] 64: - [...] 65: 3. Files and Code Sections: 66: - [File Name 1] 67: - [Summary of why this file is important] 68: - [Summary of the changes made to this file, if any] 69: - [Important Code Snippet] 70: - [File Name 2] 71: - [Important Code Snippet] 72: - [...] 73: 4. Errors and fixes: 74: - [Detailed description of error 1]: 75: - [How you fixed the error] 76: - [User feedback on the error if any] 77: - [...] 78: 5. Problem Solving: 79: [Description of solved problems and ongoing troubleshooting] 80: 6. All user messages: 81: - [Detailed non tool use user message] 82: - [...] 83: 7. Pending Tasks: 84: - [Task 1] 85: - [Task 2] 86: - [...] 87: 8. Current Work: 88: [Precise description of current work] 89: 9. Optional Next Step: 90: [Optional Next step to take] 91: </summary> 92: </example> 93: Please provide your summary based on the conversation so far, following this structure and ensuring precision and thoroughness in your response. 94: There may be additional summarization instructions provided in the included context. If so, remember to follow these instructions when creating the above summary. Examples of instructions include: 95: <example> 96: ## Compact Instructions 97: When summarizing the conversation focus on typescript code changes and also remember the mistakes you made and how you fixed them. 98: </example> 99: <example> 100: # Summary instructions 101: When you are using compact - please focus on test output and code changes. Include file reads verbatim. 102: </example> 103: ` 104: const PARTIAL_COMPACT_PROMPT = `Your task is to create a detailed summary of the RECENT portion of the conversation — the messages that follow earlier retained context. The earlier messages are being kept intact and do NOT need to be summarized. Focus your summary on what was discussed, learned, and accomplished in the recent messages only. 105: ${DETAILED_ANALYSIS_INSTRUCTION_PARTIAL} 106: Your summary should include the following sections: 107: 1. Primary Request and Intent: Capture the user's explicit requests and intents from the recent messages 108: 2. Key Technical Concepts: List important technical concepts, technologies, and frameworks discussed recently. 109: 3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Include full code snippets where applicable and include a summary of why this file read or edit is important. 110: 4. Errors and fixes: List errors encountered and how they were fixed. 111: 5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. 112: 6. All user messages: List ALL user messages from the recent portion that are not tool results. 113: 7. Pending Tasks: Outline any pending tasks from the recent messages. 114: 8. Current Work: Describe precisely what was being worked on immediately before this summary request. 115: 9. Optional Next Step: List the next step related to the most recent work. Include direct quotes from the most recent conversation. 116: Here's an example of how your output should be structured: 117: <example> 118: <analysis> 119: [Your thought process, ensuring all points are covered thoroughly and accurately] 120: </analysis> 121: <summary> 122: 1. Primary Request and Intent: 123: [Detailed description] 124: 2. Key Technical Concepts: 125: - [Concept 1] 126: - [Concept 2] 127: 3. Files and Code Sections: 128: - [File Name 1] 129: - [Summary of why this file is important] 130: - [Important Code Snippet] 131: 4. Errors and fixes: 132: - [Error description]: 133: - [How you fixed it] 134: 5. Problem Solving: 135: [Description] 136: 6. All user messages: 137: - [Detailed non tool use user message] 138: 7. Pending Tasks: 139: - [Task 1] 140: 8. Current Work: 141: [Precise description of current work] 142: 9. Optional Next Step: 143: [Optional Next step to take] 144: </summary> 145: </example> 146: Please provide your summary based on the RECENT messages only (after the retained earlier context), following this structure and ensuring precision and thoroughness in your response. 147: ` 148: const PARTIAL_COMPACT_UP_TO_PROMPT = `Your task is to create a detailed summary of this conversation. This summary will be placed at the start of a continuing session; newer messages that build on this context will follow after your summary (you do not see them here). Summarize thoroughly so that someone reading only your summary and then the newer messages can fully understand what happened and continue the work. 149: ${DETAILED_ANALYSIS_INSTRUCTION_BASE} 150: Your summary should include the following sections: 151: 1. Primary Request and Intent: Capture the user's explicit requests and intents in detail 152: 2. Key Technical Concepts: List important technical concepts, technologies, and frameworks discussed. 153: 3. Files and Code Sections: Enumerate specific files and code sections examined, modified, or created. Include full code snippets where applicable and include a summary of why this file read or edit is important. 154: 4. Errors and fixes: List errors encountered and how they were fixed. 155: 5. Problem Solving: Document problems solved and any ongoing troubleshooting efforts. 156: 6. All user messages: List ALL user messages that are not tool results. 157: 7. Pending Tasks: Outline any pending tasks. 158: 8. Work Completed: Describe what was accomplished by the end of this portion. 159: 9. Context for Continuing Work: Summarize any context, decisions, or state that would be needed to understand and continue the work in subsequent messages. 160: Here's an example of how your output should be structured: 161: <example> 162: <analysis> 163: [Your thought process, ensuring all points are covered thoroughly and accurately] 164: </analysis> 165: <summary> 166: 1. Primary Request and Intent: 167: [Detailed description] 168: 2. Key Technical Concepts: 169: - [Concept 1] 170: - [Concept 2] 171: 3. Files and Code Sections: 172: - [File Name 1] 173: - [Summary of why this file is important] 174: - [Important Code Snippet] 175: 4. Errors and fixes: 176: - [Error description]: 177: - [How you fixed it] 178: 5. Problem Solving: 179: [Description] 180: 6. All user messages: 181: - [Detailed non tool use user message] 182: 7. Pending Tasks: 183: - [Task 1] 184: 8. Work Completed: 185: [Description of what was accomplished] 186: 9. Context for Continuing Work: 187: [Key context, decisions, or state needed to continue the work] 188: </summary> 189: </example> 190: Please provide your summary following this structure, ensuring precision and thoroughness in your response. 191: ` 192: const NO_TOOLS_TRAILER = 193: '\n\nREMINDER: Do NOT call any tools. Respond with plain text only — ' + 194: 'an <analysis> block followed by a <summary> block. ' + 195: 'Tool calls will be rejected and you will fail the task.' 196: export function getPartialCompactPrompt( 197: customInstructions?: string, 198: direction: PartialCompactDirection = 'from', 199: ): string { 200: const template = 201: direction === 'up_to' 202: ? PARTIAL_COMPACT_UP_TO_PROMPT 203: : PARTIAL_COMPACT_PROMPT 204: let prompt = NO_TOOLS_PREAMBLE + template 205: if (customInstructions && customInstructions.trim() !== '') { 206: prompt += `\n\nAdditional Instructions:\n${customInstructions}` 207: } 208: prompt += NO_TOOLS_TRAILER 209: return prompt 210: } 211: export function getCompactPrompt(customInstructions?: string): string { 212: let prompt = NO_TOOLS_PREAMBLE + BASE_COMPACT_PROMPT 213: if (customInstructions && customInstructions.trim() !== '') { 214: prompt += `\n\nAdditional Instructions:\n${customInstructions}` 215: } 216: prompt += NO_TOOLS_TRAILER 217: return prompt 218: } 219: /** 220: * Formats the compact summary by stripping the <analysis> drafting scratchpad 221: * and replacing <summary> XML tags with readable section headers. 222: * @param summary The raw summary string potentially containing <analysis> and <summary> XML tags 223: * @returns The formatted summary with analysis stripped and summary tags replaced by headers 224: */ 225: export function formatCompactSummary(summary: string): string { 226: let formattedSummary = summary 227: // Strip analysis section — it's a drafting scratchpad that improves summary 228: formattedSummary = formattedSummary.replace( 229: /<analysis>[\s\S]*?<\/analysis>/, 230: '', 231: ) 232: // Extract and format summary section 233: const summaryMatch = formattedSummary.match(/<summary>([\s\S]*?)<\/summary>/) 234: if (summaryMatch) { 235: const content = summaryMatch[1] || '' 236: formattedSummary = formattedSummary.replace( 237: /<summary>[\s\S]*?<\/summary>/, 238: `Summary:\n${content.trim()}`, 239: ) 240: } 241: // Clean up extra whitespace between sections 242: formattedSummary = formattedSummary.replace(/\n\n+/g, '\n\n') 243: return formattedSummary.trim() 244: } 245: export function getCompactUserSummaryMessage( 246: summary: string, 247: suppressFollowUpQuestions?: boolean, 248: transcriptPath?: string, 249: recentMessagesPreserved?: boolean, 250: ): string { 251: const formattedSummary = formatCompactSummary(summary) 252: let baseSummary = `This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation. 253: ${formattedSummary}` 254: if (transcriptPath) { 255: baseSummary += `\n\nIf you need specific details from before compaction (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}` 256: } 257: if (recentMessagesPreserved) { 258: baseSummary += `\n\nRecent messages are preserved verbatim.` 259: } 260: if (suppressFollowUpQuestions) { 261: let continuation = `${baseSummary} 262: Continue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, do not preface with "I'll continue" or similar. Pick up the last task as if the break never happened.` 263: if ( 264: (feature('PROACTIVE') || feature('KAIROS')) && 265: proactiveModule?.isProactiveActive() 266: ) { 267: continuation += ` 268: You are running in autonomous/proactive mode. This is NOT a first wake-up — you were already working autonomously before compaction. Continue your work loop: pick up where you left off based on the summary above. Do not greet the user or ask what to work on.` 269: } 270: return continuation 271: } 272: return baseSummary 273: }

File: src/services/compact/sessionMemoryCompact.ts

typescript 1: import type { AgentId } from '../../types/ids.js' 2: import type { HookResultMessage, Message } from '../../types/message.js' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { isEnvTruthy } from '../../utils/envUtils.js' 5: import { errorMessage } from '../../utils/errors.js' 6: import { 7: createCompactBoundaryMessage, 8: createUserMessage, 9: isCompactBoundaryMessage, 10: } from '../../utils/messages.js' 11: import { getMainLoopModel } from '../../utils/model/model.js' 12: import { getSessionMemoryPath } from '../../utils/permissions/filesystem.js' 13: import { processSessionStartHooks } from '../../utils/sessionStart.js' 14: import { getTranscriptPath } from '../../utils/sessionStorage.js' 15: import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js' 16: import { extractDiscoveredToolNames } from '../../utils/toolSearch.js' 17: import { 18: getDynamicConfig_BLOCKS_ON_INIT, 19: getFeatureValue_CACHED_MAY_BE_STALE, 20: } from '../analytics/growthbook.js' 21: import { logEvent } from '../analytics/index.js' 22: import { 23: isSessionMemoryEmpty, 24: truncateSessionMemoryForCompact, 25: } from '../SessionMemory/prompts.js' 26: import { 27: getLastSummarizedMessageId, 28: getSessionMemoryContent, 29: waitForSessionMemoryExtraction, 30: } from '../SessionMemory/sessionMemoryUtils.js' 31: import { 32: annotateBoundaryWithPreservedSegment, 33: buildPostCompactMessages, 34: type CompactionResult, 35: createPlanAttachmentIfNeeded, 36: } from './compact.js' 37: import { estimateMessageTokens } from './microCompact.js' 38: import { getCompactUserSummaryMessage } from './prompt.js' 39: export type SessionMemoryCompactConfig = { 40: minTokens: number 41: minTextBlockMessages: number 42: maxTokens: number 43: } 44: export const DEFAULT_SM_COMPACT_CONFIG: SessionMemoryCompactConfig = { 45: minTokens: 10_000, 46: minTextBlockMessages: 5, 47: maxTokens: 40_000, 48: } 49: let smCompactConfig: SessionMemoryCompactConfig = { 50: ...DEFAULT_SM_COMPACT_CONFIG, 51: } 52: let configInitialized = false 53: export function setSessionMemoryCompactConfig( 54: config: Partial<SessionMemoryCompactConfig>, 55: ): void { 56: smCompactConfig = { 57: ...smCompactConfig, 58: ...config, 59: } 60: } 61: export function getSessionMemoryCompactConfig(): SessionMemoryCompactConfig { 62: return { ...smCompactConfig } 63: } 64: export function resetSessionMemoryCompactConfig(): void { 65: smCompactConfig = { ...DEFAULT_SM_COMPACT_CONFIG } 66: configInitialized = false 67: } 68: async function initSessionMemoryCompactConfig(): Promise<void> { 69: if (configInitialized) { 70: return 71: } 72: configInitialized = true 73: const remoteConfig = await getDynamicConfig_BLOCKS_ON_INIT< 74: Partial<SessionMemoryCompactConfig> 75: >('tengu_sm_compact_config', {}) 76: const config: SessionMemoryCompactConfig = { 77: minTokens: 78: remoteConfig.minTokens && remoteConfig.minTokens > 0 79: ? remoteConfig.minTokens 80: : DEFAULT_SM_COMPACT_CONFIG.minTokens, 81: minTextBlockMessages: 82: remoteConfig.minTextBlockMessages && remoteConfig.minTextBlockMessages > 0 83: ? remoteConfig.minTextBlockMessages 84: : DEFAULT_SM_COMPACT_CONFIG.minTextBlockMessages, 85: maxTokens: 86: remoteConfig.maxTokens && remoteConfig.maxTokens > 0 87: ? remoteConfig.maxTokens 88: : DEFAULT_SM_COMPACT_CONFIG.maxTokens, 89: } 90: setSessionMemoryCompactConfig(config) 91: } 92: export function hasTextBlocks(message: Message): boolean { 93: if (message.type === 'assistant') { 94: const content = message.message.content 95: return content.some(block => block.type === 'text') 96: } 97: if (message.type === 'user') { 98: const content = message.message.content 99: if (typeof content === 'string') { 100: return content.length > 0 101: } 102: if (Array.isArray(content)) { 103: return content.some(block => block.type === 'text') 104: } 105: } 106: return false 107: } 108: function getToolResultIds(message: Message): string[] { 109: if (message.type !== 'user') { 110: return [] 111: } 112: const content = message.message.content 113: if (!Array.isArray(content)) { 114: return [] 115: } 116: const ids: string[] = [] 117: for (const block of content) { 118: if (block.type === 'tool_result') { 119: ids.push(block.tool_use_id) 120: } 121: } 122: return ids 123: } 124: function hasToolUseWithIds(message: Message, toolUseIds: Set<string>): boolean { 125: if (message.type !== 'assistant') { 126: return false 127: } 128: const content = message.message.content 129: if (!Array.isArray(content)) { 130: return false 131: } 132: return content.some( 133: block => block.type === 'tool_use' && toolUseIds.has(block.id), 134: ) 135: } 136: export function adjustIndexToPreserveAPIInvariants( 137: messages: Message[], 138: startIndex: number, 139: ): number { 140: if (startIndex <= 0 || startIndex >= messages.length) { 141: return startIndex 142: } 143: let adjustedIndex = startIndex 144: const allToolResultIds: string[] = [] 145: for (let i = startIndex; i < messages.length; i++) { 146: allToolResultIds.push(...getToolResultIds(messages[i]!)) 147: } 148: if (allToolResultIds.length > 0) { 149: const toolUseIdsInKeptRange = new Set<string>() 150: for (let i = adjustedIndex; i < messages.length; i++) { 151: const msg = messages[i]! 152: if (msg.type === 'assistant' && Array.isArray(msg.message.content)) { 153: for (const block of msg.message.content) { 154: if (block.type === 'tool_use') { 155: toolUseIdsInKeptRange.add(block.id) 156: } 157: } 158: } 159: } 160: const neededToolUseIds = new Set( 161: allToolResultIds.filter(id => !toolUseIdsInKeptRange.has(id)), 162: ) 163: for (let i = adjustedIndex - 1; i >= 0 && neededToolUseIds.size > 0; i--) { 164: const message = messages[i]! 165: if (hasToolUseWithIds(message, neededToolUseIds)) { 166: adjustedIndex = i 167: if ( 168: message.type === 'assistant' && 169: Array.isArray(message.message.content) 170: ) { 171: for (const block of message.message.content) { 172: if (block.type === 'tool_use' && neededToolUseIds.has(block.id)) { 173: neededToolUseIds.delete(block.id) 174: } 175: } 176: } 177: } 178: } 179: } 180: const messageIdsInKeptRange = new Set<string>() 181: for (let i = adjustedIndex; i < messages.length; i++) { 182: const msg = messages[i]! 183: if (msg.type === 'assistant' && msg.message.id) { 184: messageIdsInKeptRange.add(msg.message.id) 185: } 186: } 187: for (let i = adjustedIndex - 1; i >= 0; i--) { 188: const message = messages[i]! 189: if ( 190: message.type === 'assistant' && 191: message.message.id && 192: messageIdsInKeptRange.has(message.message.id) 193: ) { 194: adjustedIndex = i 195: } 196: } 197: return adjustedIndex 198: } 199: export function calculateMessagesToKeepIndex( 200: messages: Message[], 201: lastSummarizedIndex: number, 202: ): number { 203: if (messages.length === 0) { 204: return 0 205: } 206: const config = getSessionMemoryCompactConfig() 207: let startIndex = 208: lastSummarizedIndex >= 0 ? lastSummarizedIndex + 1 : messages.length 209: let totalTokens = 0 210: let textBlockMessageCount = 0 211: for (let i = startIndex; i < messages.length; i++) { 212: const msg = messages[i]! 213: totalTokens += estimateMessageTokens([msg]) 214: if (hasTextBlocks(msg)) { 215: textBlockMessageCount++ 216: } 217: } 218: if (totalTokens >= config.maxTokens) { 219: return adjustIndexToPreserveAPIInvariants(messages, startIndex) 220: } 221: if ( 222: totalTokens >= config.minTokens && 223: textBlockMessageCount >= config.minTextBlockMessages 224: ) { 225: return adjustIndexToPreserveAPIInvariants(messages, startIndex) 226: } 227: const idx = messages.findLastIndex(m => isCompactBoundaryMessage(m)) 228: const floor = idx === -1 ? 0 : idx + 1 229: for (let i = startIndex - 1; i >= floor; i--) { 230: const msg = messages[i]! 231: const msgTokens = estimateMessageTokens([msg]) 232: totalTokens += msgTokens 233: if (hasTextBlocks(msg)) { 234: textBlockMessageCount++ 235: } 236: startIndex = i 237: if (totalTokens >= config.maxTokens) { 238: break 239: } 240: if ( 241: totalTokens >= config.minTokens && 242: textBlockMessageCount >= config.minTextBlockMessages 243: ) { 244: break 245: } 246: } 247: return adjustIndexToPreserveAPIInvariants(messages, startIndex) 248: } 249: export function shouldUseSessionMemoryCompaction(): boolean { 250: if (isEnvTruthy(process.env.ENABLE_CLAUDE_CODE_SM_COMPACT)) { 251: return true 252: } 253: if (isEnvTruthy(process.env.DISABLE_CLAUDE_CODE_SM_COMPACT)) { 254: return false 255: } 256: const sessionMemoryFlag = getFeatureValue_CACHED_MAY_BE_STALE( 257: 'tengu_session_memory', 258: false, 259: ) 260: const smCompactFlag = getFeatureValue_CACHED_MAY_BE_STALE( 261: 'tengu_sm_compact', 262: false, 263: ) 264: const shouldUse = sessionMemoryFlag && smCompactFlag 265: if (process.env.USER_TYPE === 'ant') { 266: logEvent('tengu_sm_compact_flag_check', { 267: tengu_session_memory: sessionMemoryFlag, 268: tengu_sm_compact: smCompactFlag, 269: should_use: shouldUse, 270: }) 271: } 272: return shouldUse 273: } 274: function createCompactionResultFromSessionMemory( 275: messages: Message[], 276: sessionMemory: string, 277: messagesToKeep: Message[], 278: hookResults: HookResultMessage[], 279: transcriptPath: string, 280: agentId?: AgentId, 281: ): CompactionResult { 282: const preCompactTokenCount = tokenCountFromLastAPIResponse(messages) 283: const boundaryMarker = createCompactBoundaryMessage( 284: 'auto', 285: preCompactTokenCount ?? 0, 286: messages[messages.length - 1]?.uuid, 287: ) 288: const preCompactDiscovered = extractDiscoveredToolNames(messages) 289: if (preCompactDiscovered.size > 0) { 290: boundaryMarker.compactMetadata.preCompactDiscoveredTools = [ 291: ...preCompactDiscovered, 292: ].sort() 293: } 294: const { truncatedContent, wasTruncated } = 295: truncateSessionMemoryForCompact(sessionMemory) 296: let summaryContent = getCompactUserSummaryMessage( 297: truncatedContent, 298: true, 299: transcriptPath, 300: true, 301: ) 302: if (wasTruncated) { 303: const memoryPath = getSessionMemoryPath() 304: summaryContent += `\n\nSome session memory sections were truncated for length. The full session memory can be viewed at: ${memoryPath}` 305: } 306: const summaryMessages = [ 307: createUserMessage({ 308: content: summaryContent, 309: isCompactSummary: true, 310: isVisibleInTranscriptOnly: true, 311: }), 312: ] 313: const planAttachment = createPlanAttachmentIfNeeded(agentId) 314: const attachments = planAttachment ? [planAttachment] : [] 315: return { 316: boundaryMarker: annotateBoundaryWithPreservedSegment( 317: boundaryMarker, 318: summaryMessages[summaryMessages.length - 1]!.uuid, 319: messagesToKeep, 320: ), 321: summaryMessages, 322: attachments, 323: hookResults, 324: messagesToKeep, 325: preCompactTokenCount, 326: postCompactTokenCount: estimateMessageTokens(summaryMessages), 327: truePostCompactTokenCount: estimateMessageTokens(summaryMessages), 328: } 329: } 330: export async function trySessionMemoryCompaction( 331: messages: Message[], 332: agentId?: AgentId, 333: autoCompactThreshold?: number, 334: ): Promise<CompactionResult | null> { 335: if (!shouldUseSessionMemoryCompaction()) { 336: return null 337: } 338: await initSessionMemoryCompactConfig() 339: await waitForSessionMemoryExtraction() 340: const lastSummarizedMessageId = getLastSummarizedMessageId() 341: const sessionMemory = await getSessionMemoryContent() 342: if (!sessionMemory) { 343: logEvent('tengu_sm_compact_no_session_memory', {}) 344: return null 345: } 346: if (await isSessionMemoryEmpty(sessionMemory)) { 347: logEvent('tengu_sm_compact_empty_template', {}) 348: return null 349: } 350: try { 351: let lastSummarizedIndex: number 352: if (lastSummarizedMessageId) { 353: lastSummarizedIndex = messages.findIndex( 354: msg => msg.uuid === lastSummarizedMessageId, 355: ) 356: if (lastSummarizedIndex === -1) { 357: logEvent('tengu_sm_compact_summarized_id_not_found', {}) 358: return null 359: } 360: } else { 361: lastSummarizedIndex = messages.length - 1 362: logEvent('tengu_sm_compact_resumed_session', {}) 363: } 364: const startIndex = calculateMessagesToKeepIndex( 365: messages, 366: lastSummarizedIndex, 367: ) 368: const messagesToKeep = messages 369: .slice(startIndex) 370: .filter(m => !isCompactBoundaryMessage(m)) 371: const hookResults = await processSessionStartHooks('compact', { 372: model: getMainLoopModel(), 373: }) 374: const transcriptPath = getTranscriptPath() 375: const compactionResult = createCompactionResultFromSessionMemory( 376: messages, 377: sessionMemory, 378: messagesToKeep, 379: hookResults, 380: transcriptPath, 381: agentId, 382: ) 383: const postCompactMessages = buildPostCompactMessages(compactionResult) 384: const postCompactTokenCount = estimateMessageTokens(postCompactMessages) 385: if ( 386: autoCompactThreshold !== undefined && 387: postCompactTokenCount >= autoCompactThreshold 388: ) { 389: logEvent('tengu_sm_compact_threshold_exceeded', { 390: postCompactTokenCount, 391: autoCompactThreshold, 392: }) 393: return null 394: } 395: return { 396: ...compactionResult, 397: postCompactTokenCount, 398: truePostCompactTokenCount: postCompactTokenCount, 399: } 400: } catch (error) { 401: logEvent('tengu_sm_compact_error', {}) 402: if (process.env.USER_TYPE === 'ant') { 403: logForDebugging(`Session memory compaction error: ${errorMessage(error)}`) 404: } 405: return null 406: } 407: }

File: src/services/compact/timeBasedMCConfig.ts

typescript 1: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 2: export type TimeBasedMCConfig = { 3: enabled: boolean 4: gapThresholdMinutes: number 5: keepRecent: number 6: } 7: const TIME_BASED_MC_CONFIG_DEFAULTS: TimeBasedMCConfig = { 8: enabled: false, 9: gapThresholdMinutes: 60, 10: keepRecent: 5, 11: } 12: export function getTimeBasedMCConfig(): TimeBasedMCConfig { 13: return getFeatureValue_CACHED_MAY_BE_STALE<TimeBasedMCConfig>( 14: 'tengu_slate_heron', 15: TIME_BASED_MC_CONFIG_DEFAULTS, 16: ) 17: }

File: src/services/extractMemories/extractMemories.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { basename } from 'path' 3: import { getIsRemoteMode } from '../../bootstrap/state.js' 4: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 5: import { ENTRYPOINT_NAME } from '../../memdir/memdir.js' 6: import { 7: formatMemoryManifest, 8: scanMemoryFiles, 9: } from '../../memdir/memoryScan.js' 10: import { 11: getAutoMemPath, 12: isAutoMemoryEnabled, 13: isAutoMemPath, 14: } from '../../memdir/paths.js' 15: import type { Tool } from '../../Tool.js' 16: import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 17: import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 18: import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' 19: import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' 20: import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js' 21: import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js' 22: import { REPL_TOOL_NAME } from '../../tools/REPLTool/constants.js' 23: import type { 24: AssistantMessage, 25: Message, 26: SystemLocalCommandMessage, 27: SystemMessage, 28: } from '../../types/message.js' 29: import { createAbortController } from '../../utils/abortController.js' 30: import { count, uniq } from '../../utils/array.js' 31: import { logForDebugging } from '../../utils/debug.js' 32: import { 33: createCacheSafeParams, 34: runForkedAgent, 35: } from '../../utils/forkedAgent.js' 36: import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' 37: import { 38: createMemorySavedMessage, 39: createUserMessage, 40: } from '../../utils/messages.js' 41: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 42: import { logEvent } from '../analytics/index.js' 43: import { sanitizeToolNameForAnalytics } from '../analytics/metadata.js' 44: import { 45: buildExtractAutoOnlyPrompt, 46: buildExtractCombinedPrompt, 47: } from './prompts.js' 48: const teamMemPaths = feature('TEAMMEM') 49: ? (require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js')) 50: : null 51: function isModelVisibleMessage(message: Message): boolean { 52: return message.type === 'user' || message.type === 'assistant' 53: } 54: function countModelVisibleMessagesSince( 55: messages: Message[], 56: sinceUuid: string | undefined, 57: ): number { 58: if (sinceUuid === null || sinceUuid === undefined) { 59: return count(messages, isModelVisibleMessage) 60: } 61: let foundStart = false 62: let n = 0 63: for (const message of messages) { 64: if (!foundStart) { 65: if (message.uuid === sinceUuid) { 66: foundStart = true 67: } 68: continue 69: } 70: if (isModelVisibleMessage(message)) { 71: n++ 72: } 73: } 74: if (!foundStart) { 75: return count(messages, isModelVisibleMessage) 76: } 77: return n 78: } 79: function hasMemoryWritesSince( 80: messages: Message[], 81: sinceUuid: string | undefined, 82: ): boolean { 83: let foundStart = sinceUuid === undefined 84: for (const message of messages) { 85: if (!foundStart) { 86: if (message.uuid === sinceUuid) { 87: foundStart = true 88: } 89: continue 90: } 91: if (message.type !== 'assistant') { 92: continue 93: } 94: const content = (message as AssistantMessage).message.content 95: if (!Array.isArray(content)) { 96: continue 97: } 98: for (const block of content) { 99: const filePath = getWrittenFilePath(block) 100: if (filePath !== undefined && isAutoMemPath(filePath)) { 101: return true 102: } 103: } 104: } 105: return false 106: } 107: function denyAutoMemTool(tool: Tool, reason: string) { 108: logForDebugging(`[autoMem] denied ${tool.name}: ${reason}`) 109: logEvent('tengu_auto_mem_tool_denied', { 110: tool_name: sanitizeToolNameForAnalytics(tool.name), 111: }) 112: return { 113: behavior: 'deny' as const, 114: message: reason, 115: decisionReason: { type: 'other' as const, reason }, 116: } 117: } 118: export function createAutoMemCanUseTool(memoryDir: string): CanUseToolFn { 119: return async (tool: Tool, input: Record<string, unknown>) => { 120: if (tool.name === REPL_TOOL_NAME) { 121: return { behavior: 'allow' as const, updatedInput: input } 122: } 123: if ( 124: tool.name === FILE_READ_TOOL_NAME || 125: tool.name === GREP_TOOL_NAME || 126: tool.name === GLOB_TOOL_NAME 127: ) { 128: return { behavior: 'allow' as const, updatedInput: input } 129: } 130: if (tool.name === BASH_TOOL_NAME) { 131: const parsed = tool.inputSchema.safeParse(input) 132: if (parsed.success && tool.isReadOnly(parsed.data)) { 133: return { behavior: 'allow' as const, updatedInput: input } 134: } 135: return denyAutoMemTool( 136: tool, 137: 'Only read-only shell commands are permitted in this context (ls, find, grep, cat, stat, wc, head, tail, and similar)', 138: ) 139: } 140: if ( 141: (tool.name === FILE_EDIT_TOOL_NAME || 142: tool.name === FILE_WRITE_TOOL_NAME) && 143: 'file_path' in input 144: ) { 145: const filePath = input.file_path 146: if (typeof filePath === 'string' && isAutoMemPath(filePath)) { 147: return { behavior: 'allow' as const, updatedInput: input } 148: } 149: } 150: return denyAutoMemTool( 151: tool, 152: `only ${FILE_READ_TOOL_NAME}, ${GREP_TOOL_NAME}, ${GLOB_TOOL_NAME}, read-only ${BASH_TOOL_NAME}, and ${FILE_EDIT_TOOL_NAME}/${FILE_WRITE_TOOL_NAME} within ${memoryDir} are allowed`, 153: ) 154: } 155: } 156: function getWrittenFilePath(block: { 157: type: string 158: name?: string 159: input?: unknown 160: }): string | undefined { 161: if ( 162: block.type !== 'tool_use' || 163: (block.name !== FILE_EDIT_TOOL_NAME && block.name !== FILE_WRITE_TOOL_NAME) 164: ) { 165: return undefined 166: } 167: const input = block.input 168: if (typeof input === 'object' && input !== null && 'file_path' in input) { 169: const fp = (input as { file_path: unknown }).file_path 170: return typeof fp === 'string' ? fp : undefined 171: } 172: return undefined 173: } 174: function extractWrittenPaths(agentMessages: Message[]): string[] { 175: const paths: string[] = [] 176: for (const message of agentMessages) { 177: if (message.type !== 'assistant') { 178: continue 179: } 180: const content = (message as AssistantMessage).message.content 181: if (!Array.isArray(content)) { 182: continue 183: } 184: for (const block of content) { 185: const filePath = getWrittenFilePath(block) 186: if (filePath !== undefined) { 187: paths.push(filePath) 188: } 189: } 190: } 191: return uniq(paths) 192: } 193: type AppendSystemMessageFn = ( 194: msg: Exclude<SystemMessage, SystemLocalCommandMessage>, 195: ) => void 196: let extractor: 197: | (( 198: context: REPLHookContext, 199: appendSystemMessage?: AppendSystemMessageFn, 200: ) => Promise<void>) 201: | null = null 202: let drainer: (timeoutMs?: number) => Promise<void> = async () => {} 203: export function initExtractMemories(): void { 204: const inFlightExtractions = new Set<Promise<void>>() 205: let lastMemoryMessageUuid: string | undefined 206: let hasLoggedGateFailure = false 207: let inProgress = false 208: let turnsSinceLastExtraction = 0 209: let pendingContext: 210: | { 211: context: REPLHookContext 212: appendSystemMessage?: AppendSystemMessageFn 213: } 214: | undefined 215: async function runExtraction({ 216: context, 217: appendSystemMessage, 218: isTrailingRun, 219: }: { 220: context: REPLHookContext 221: appendSystemMessage?: AppendSystemMessageFn 222: isTrailingRun?: boolean 223: }): Promise<void> { 224: const { messages } = context 225: const memoryDir = getAutoMemPath() 226: const newMessageCount = countModelVisibleMessagesSince( 227: messages, 228: lastMemoryMessageUuid, 229: ) 230: if (hasMemoryWritesSince(messages, lastMemoryMessageUuid)) { 231: logForDebugging( 232: '[extractMemories] skipping — conversation already wrote to memory files', 233: ) 234: const lastMessage = messages.at(-1) 235: if (lastMessage?.uuid) { 236: lastMemoryMessageUuid = lastMessage.uuid 237: } 238: logEvent('tengu_extract_memories_skipped_direct_write', { 239: message_count: newMessageCount, 240: }) 241: return 242: } 243: const teamMemoryEnabled = feature('TEAMMEM') 244: ? teamMemPaths!.isTeamMemoryEnabled() 245: : false 246: const skipIndex = getFeatureValue_CACHED_MAY_BE_STALE( 247: 'tengu_moth_copse', 248: false, 249: ) 250: const canUseTool = createAutoMemCanUseTool(memoryDir) 251: const cacheSafeParams = createCacheSafeParams(context) 252: if (!isTrailingRun) { 253: turnsSinceLastExtraction++ 254: if ( 255: turnsSinceLastExtraction < 256: (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bramble_lintel', null) ?? 1) 257: ) { 258: return 259: } 260: } 261: turnsSinceLastExtraction = 0 262: inProgress = true 263: const startTime = Date.now() 264: try { 265: logForDebugging( 266: `[extractMemories] starting — ${newMessageCount} new messages, memoryDir=${memoryDir}`, 267: ) 268: const existingMemories = formatMemoryManifest( 269: await scanMemoryFiles(memoryDir, createAbortController().signal), 270: ) 271: const userPrompt = 272: feature('TEAMMEM') && teamMemoryEnabled 273: ? buildExtractCombinedPrompt( 274: newMessageCount, 275: existingMemories, 276: skipIndex, 277: ) 278: : buildExtractAutoOnlyPrompt( 279: newMessageCount, 280: existingMemories, 281: skipIndex, 282: ) 283: const result = await runForkedAgent({ 284: promptMessages: [createUserMessage({ content: userPrompt })], 285: cacheSafeParams, 286: canUseTool, 287: querySource: 'extract_memories', 288: forkLabel: 'extract_memories', 289: skipTranscript: true, 290: maxTurns: 5, 291: }) 292: const lastMessage = messages.at(-1) 293: if (lastMessage?.uuid) { 294: lastMemoryMessageUuid = lastMessage.uuid 295: } 296: const writtenPaths = extractWrittenPaths(result.messages) 297: const turnCount = count(result.messages, m => m.type === 'assistant') 298: const totalInput = 299: result.totalUsage.input_tokens + 300: result.totalUsage.cache_creation_input_tokens + 301: result.totalUsage.cache_read_input_tokens 302: const hitPct = 303: totalInput > 0 304: ? ( 305: (result.totalUsage.cache_read_input_tokens / totalInput) * 306: 100 307: ).toFixed(1) 308: : '0.0' 309: logForDebugging( 310: `[extractMemories] finished — ${writtenPaths.length} files written, cache: read=${result.totalUsage.cache_read_input_tokens} create=${result.totalUsage.cache_creation_input_tokens} input=${result.totalUsage.input_tokens} (${hitPct}% hit)`, 311: ) 312: if (writtenPaths.length > 0) { 313: logForDebugging( 314: `[extractMemories] memories saved: ${writtenPaths.join(', ')}`, 315: ) 316: } else { 317: logForDebugging('[extractMemories] no memories saved this run') 318: } 319: const memoryPaths = writtenPaths.filter( 320: p => basename(p) !== ENTRYPOINT_NAME, 321: ) 322: const teamCount = feature('TEAMMEM') 323: ? count(memoryPaths, teamMemPaths!.isTeamMemPath) 324: : 0 325: logEvent('tengu_extract_memories_extraction', { 326: input_tokens: result.totalUsage.input_tokens, 327: output_tokens: result.totalUsage.output_tokens, 328: cache_read_input_tokens: result.totalUsage.cache_read_input_tokens, 329: cache_creation_input_tokens: 330: result.totalUsage.cache_creation_input_tokens, 331: message_count: newMessageCount, 332: turn_count: turnCount, 333: files_written: writtenPaths.length, 334: memories_saved: memoryPaths.length, 335: team_memories_saved: teamCount, 336: duration_ms: Date.now() - startTime, 337: }) 338: logForDebugging( 339: `[extractMemories] writtenPaths=${writtenPaths.length} memoryPaths=${memoryPaths.length} appendSystemMessage defined=${appendSystemMessage != null}`, 340: ) 341: if (memoryPaths.length > 0) { 342: const msg = createMemorySavedMessage(memoryPaths) 343: if (feature('TEAMMEM')) { 344: msg.teamCount = teamCount 345: } 346: appendSystemMessage?.(msg) 347: } 348: } catch (error) { 349: logForDebugging(`[extractMemories] error: ${error}`) 350: logEvent('tengu_extract_memories_error', { 351: duration_ms: Date.now() - startTime, 352: }) 353: } finally { 354: inProgress = false 355: const trailing = pendingContext 356: pendingContext = undefined 357: if (trailing) { 358: logForDebugging( 359: '[extractMemories] running trailing extraction for stashed context', 360: ) 361: await runExtraction({ 362: context: trailing.context, 363: appendSystemMessage: trailing.appendSystemMessage, 364: isTrailingRun: true, 365: }) 366: } 367: } 368: } 369: async function executeExtractMemoriesImpl( 370: context: REPLHookContext, 371: appendSystemMessage?: AppendSystemMessageFn, 372: ): Promise<void> { 373: if (context.toolUseContext.agentId) { 374: return 375: } 376: if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_passport_quail', false)) { 377: if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) { 378: hasLoggedGateFailure = true 379: logEvent('tengu_extract_memories_gate_disabled', {}) 380: } 381: return 382: } 383: if (!isAutoMemoryEnabled()) { 384: return 385: } 386: if (getIsRemoteMode()) { 387: return 388: } 389: if (inProgress) { 390: logForDebugging( 391: '[extractMemories] extraction in progress — stashing for trailing run', 392: ) 393: logEvent('tengu_extract_memories_coalesced', {}) 394: pendingContext = { context, appendSystemMessage } 395: return 396: } 397: await runExtraction({ context, appendSystemMessage }) 398: } 399: extractor = async (context, appendSystemMessage) => { 400: const p = executeExtractMemoriesImpl(context, appendSystemMessage) 401: inFlightExtractions.add(p) 402: try { 403: await p 404: } finally { 405: inFlightExtractions.delete(p) 406: } 407: } 408: drainer = async (timeoutMs = 60_000) => { 409: if (inFlightExtractions.size === 0) return 410: await Promise.race([ 411: Promise.all(inFlightExtractions).catch(() => {}), 412: new Promise<void>(r => setTimeout(r, timeoutMs).unref()), 413: ]) 414: } 415: } 416: export async function executeExtractMemories( 417: context: REPLHookContext, 418: appendSystemMessage?: AppendSystemMessageFn, 419: ): Promise<void> { 420: await extractor?.(context, appendSystemMessage) 421: } 422: export async function drainPendingExtraction( 423: timeoutMs?: number, 424: ): Promise<void> { 425: await drainer(timeoutMs) 426: }

File: src/services/extractMemories/prompts.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { 3: MEMORY_FRONTMATTER_EXAMPLE, 4: TYPES_SECTION_COMBINED, 5: TYPES_SECTION_INDIVIDUAL, 6: WHAT_NOT_TO_SAVE_SECTION, 7: } from '../../memdir/memoryTypes.js' 8: import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 9: import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 10: import { FILE_READ_TOOL_NAME } from '../../tools/FileReadTool/prompt.js' 11: import { FILE_WRITE_TOOL_NAME } from '../../tools/FileWriteTool/prompt.js' 12: import { GLOB_TOOL_NAME } from '../../tools/GlobTool/prompt.js' 13: import { GREP_TOOL_NAME } from '../../tools/GrepTool/prompt.js' 14: function opener(newMessageCount: number, existingMemories: string): string { 15: const manifest = 16: existingMemories.length > 0 17: ? `\n\n## Existing memory files\n\n${existingMemories}\n\nCheck this list before writing — update an existing file rather than creating a duplicate.` 18: : '' 19: return [ 20: `You are now acting as the memory extraction subagent. Analyze the most recent ~${newMessageCount} messages above and use them to update your persistent memory systems.`, 21: '', 22: `Available tools: ${FILE_READ_TOOL_NAME}, ${GREP_TOOL_NAME}, ${GLOB_TOOL_NAME}, read-only ${BASH_TOOL_NAME} (ls/find/cat/stat/wc/head/tail and similar), and ${FILE_EDIT_TOOL_NAME}/${FILE_WRITE_TOOL_NAME} for paths inside the memory directory only. ${BASH_TOOL_NAME} rm is not permitted. All other tools — MCP, Agent, write-capable ${BASH_TOOL_NAME}, etc — will be denied.`, 23: '', 24: `You have a limited turn budget. ${FILE_EDIT_TOOL_NAME} requires a prior ${FILE_READ_TOOL_NAME} of the same file, so the efficient strategy is: turn 1 — issue all ${FILE_READ_TOOL_NAME} calls in parallel for every file you might update; turn 2 — issue all ${FILE_WRITE_TOOL_NAME}/${FILE_EDIT_TOOL_NAME} calls in parallel. Do not interleave reads and writes across multiple turns.`, 25: '', 26: `You MUST only use content from the last ~${newMessageCount} messages to update your persistent memories. Do not waste any turns attempting to investigate or verify that content further — no grepping source files, no reading code to confirm a pattern exists, no git commands.` + 27: manifest, 28: ].join('\n') 29: } 30: export function buildExtractAutoOnlyPrompt( 31: newMessageCount: number, 32: existingMemories: string, 33: skipIndex = false, 34: ): string { 35: const howToSave = skipIndex 36: ? [ 37: '## How to save memories', 38: '', 39: 'Write each memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:', 40: '', 41: ...MEMORY_FRONTMATTER_EXAMPLE, 42: '', 43: '- Organize memory semantically by topic, not chronologically', 44: '- Update or remove memories that turn out to be wrong or outdated', 45: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 46: ] 47: : [ 48: '## How to save memories', 49: '', 50: 'Saving a memory is a two-step process:', 51: '', 52: '**Step 1** — write the memory to its own file (e.g., `user_role.md`, `feedback_testing.md`) using this frontmatter format:', 53: '', 54: ...MEMORY_FRONTMATTER_EXAMPLE, 55: '', 56: '**Step 2** — add a pointer to that file in `MEMORY.md`. `MEMORY.md` is an index, not a memory — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. It has no frontmatter. Never write memory content directly into `MEMORY.md`.', 57: '', 58: '- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep the index concise', 59: '- Organize memory semantically by topic, not chronologically', 60: '- Update or remove memories that turn out to be wrong or outdated', 61: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 62: ] 63: return [ 64: opener(newMessageCount, existingMemories), 65: '', 66: 'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.', 67: '', 68: ...TYPES_SECTION_INDIVIDUAL, 69: ...WHAT_NOT_TO_SAVE_SECTION, 70: '', 71: ...howToSave, 72: ].join('\n') 73: } 74: export function buildExtractCombinedPrompt( 75: newMessageCount: number, 76: existingMemories: string, 77: skipIndex = false, 78: ): string { 79: if (!feature('TEAMMEM')) { 80: return buildExtractAutoOnlyPrompt( 81: newMessageCount, 82: existingMemories, 83: skipIndex, 84: ) 85: } 86: const howToSave = skipIndex 87: ? [ 88: '## How to save memories', 89: '', 90: "Write each memory to its own file in the chosen directory (private or team, per the type's scope guidance) using this frontmatter format:", 91: '', 92: ...MEMORY_FRONTMATTER_EXAMPLE, 93: '', 94: '- Organize memory semantically by topic, not chronologically', 95: '- Update or remove memories that turn out to be wrong or outdated', 96: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 97: ] 98: : [ 99: '## How to save memories', 100: '', 101: 'Saving a memory is a two-step process:', 102: '', 103: "**Step 1** — write the memory to its own file in the chosen directory (private or team, per the type's scope guidance) using this frontmatter format:", 104: '', 105: ...MEMORY_FRONTMATTER_EXAMPLE, 106: '', 107: "**Step 2** — add a pointer to that file in the same directory's `MEMORY.md`. Each directory (private and team) has its own `MEMORY.md` index — each entry should be one line, under ~150 characters: `- [Title](file.md) — one-line hook`. They have no frontmatter. Never write memory content directly into a `MEMORY.md`.", 108: '', 109: '- Both `MEMORY.md` indexes are loaded into your system prompt — lines after 200 will be truncated, so keep them concise', 110: '- Organize memory semantically by topic, not chronologically', 111: '- Update or remove memories that turn out to be wrong or outdated', 112: '- Do not write duplicate memories. First check if there is an existing memory you can update before writing a new one.', 113: ] 114: return [ 115: opener(newMessageCount, existingMemories), 116: '', 117: 'If the user explicitly asks you to remember something, save it immediately as whichever type fits best. If they ask you to forget something, find and remove the relevant entry.', 118: '', 119: ...TYPES_SECTION_COMBINED, 120: ...WHAT_NOT_TO_SAVE_SECTION, 121: '- You MUST avoid saving sensitive data within shared team memories. For example, never save API keys or user credentials.', 122: '', 123: ...howToSave, 124: ].join('\n') 125: }

File: src/services/lsp/config.ts

typescript 1: import type { PluginError } from '../../types/plugin.js' 2: import { logForDebugging } from '../../utils/debug.js' 3: import { errorMessage, toError } from '../../utils/errors.js' 4: import { logError } from '../../utils/log.js' 5: import { getPluginLspServers } from '../../utils/plugins/lspPluginIntegration.js' 6: import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' 7: import type { ScopedLspServerConfig } from './types.js' 8: export async function getAllLspServers(): Promise<{ 9: servers: Record<string, ScopedLspServerConfig> 10: }> { 11: const allServers: Record<string, ScopedLspServerConfig> = {} 12: try { 13: const { enabled: plugins } = await loadAllPluginsCacheOnly() 14: const results = await Promise.all( 15: plugins.map(async plugin => { 16: const errors: PluginError[] = [] 17: try { 18: const scopedServers = await getPluginLspServers(plugin, errors) 19: return { plugin, scopedServers, errors } 20: } catch (e) { 21: logForDebugging( 22: `Failed to load LSP servers for plugin ${plugin.name}: ${e}`, 23: { level: 'error' }, 24: ) 25: return { plugin, scopedServers: undefined, errors } 26: } 27: }), 28: ) 29: for (const { plugin, scopedServers, errors } of results) { 30: const serverCount = scopedServers ? Object.keys(scopedServers).length : 0 31: if (serverCount > 0) { 32: Object.assign(allServers, scopedServers) 33: logForDebugging( 34: `Loaded ${serverCount} LSP server(s) from plugin: ${plugin.name}`, 35: ) 36: } 37: if (errors.length > 0) { 38: logForDebugging( 39: `${errors.length} error(s) loading LSP servers from plugin: ${plugin.name}`, 40: ) 41: } 42: } 43: logForDebugging( 44: `Total LSP servers loaded: ${Object.keys(allServers).length}`, 45: ) 46: } catch (error) { 47: logError(toError(error)) 48: logForDebugging(`Error loading LSP servers: ${errorMessage(error)}`) 49: } 50: return { 51: servers: allServers, 52: } 53: }

File: src/services/lsp/LSPClient.ts

typescript 1: import { type ChildProcess, spawn } from 'child_process' 2: import { 3: createMessageConnection, 4: type MessageConnection, 5: StreamMessageReader, 6: StreamMessageWriter, 7: Trace, 8: } from 'vscode-jsonrpc/node.js' 9: import type { 10: InitializeParams, 11: InitializeResult, 12: ServerCapabilities, 13: } from 'vscode-languageserver-protocol' 14: import { logForDebugging } from '../../utils/debug.js' 15: import { errorMessage } from '../../utils/errors.js' 16: import { logError } from '../../utils/log.js' 17: import { subprocessEnv } from '../../utils/subprocessEnv.js' 18: export type LSPClient = { 19: readonly capabilities: ServerCapabilities | undefined 20: readonly isInitialized: boolean 21: start: ( 22: command: string, 23: args: string[], 24: options?: { 25: env?: Record<string, string> 26: cwd?: string 27: }, 28: ) => Promise<void> 29: initialize: (params: InitializeParams) => Promise<InitializeResult> 30: sendRequest: <TResult>(method: string, params: unknown) => Promise<TResult> 31: sendNotification: (method: string, params: unknown) => Promise<void> 32: onNotification: (method: string, handler: (params: unknown) => void) => void 33: onRequest: <TParams, TResult>( 34: method: string, 35: handler: (params: TParams) => TResult | Promise<TResult>, 36: ) => void 37: stop: () => Promise<void> 38: } 39: export function createLSPClient( 40: serverName: string, 41: onCrash?: (error: Error) => void, 42: ): LSPClient { 43: let process: ChildProcess | undefined 44: let connection: MessageConnection | undefined 45: let capabilities: ServerCapabilities | undefined 46: let isInitialized = false 47: let startFailed = false 48: let startError: Error | undefined 49: let isStopping = false 50: const pendingHandlers: Array<{ 51: method: string 52: handler: (params: unknown) => void 53: }> = [] 54: const pendingRequestHandlers: Array<{ 55: method: string 56: handler: (params: unknown) => unknown | Promise<unknown> 57: }> = [] 58: function checkStartFailed(): void { 59: if (startFailed) { 60: throw startError || new Error(`LSP server ${serverName} failed to start`) 61: } 62: } 63: return { 64: get capabilities(): ServerCapabilities | undefined { 65: return capabilities 66: }, 67: get isInitialized(): boolean { 68: return isInitialized 69: }, 70: async start( 71: command: string, 72: args: string[], 73: options?: { 74: env?: Record<string, string> 75: cwd?: string 76: }, 77: ): Promise<void> { 78: try { 79: process = spawn(command, args, { 80: stdio: ['pipe', 'pipe', 'pipe'], 81: env: { ...subprocessEnv(), ...options?.env }, 82: cwd: options?.cwd, 83: windowsHide: true, 84: }) 85: if (!process.stdout || !process.stdin) { 86: throw new Error('LSP server process stdio not available') 87: } 88: const spawnedProcess = process 89: await new Promise<void>((resolve, reject) => { 90: const onSpawn = (): void => { 91: cleanup() 92: resolve() 93: } 94: const onError = (error: Error): void => { 95: cleanup() 96: reject(error) 97: } 98: const cleanup = (): void => { 99: spawnedProcess.removeListener('spawn', onSpawn) 100: spawnedProcess.removeListener('error', onError) 101: } 102: spawnedProcess.once('spawn', onSpawn) 103: spawnedProcess.once('error', onError) 104: }) 105: if (process.stderr) { 106: process.stderr.on('data', (data: Buffer) => { 107: const output = data.toString().trim() 108: if (output) { 109: logForDebugging(`[LSP SERVER ${serverName}] ${output}`) 110: } 111: }) 112: } 113: process.on('error', error => { 114: if (!isStopping) { 115: startFailed = true 116: startError = error 117: logError( 118: new Error( 119: `LSP server ${serverName} failed to start: ${error.message}`, 120: ), 121: ) 122: } 123: }) 124: process.on('exit', (code, _signal) => { 125: if (code !== 0 && code !== null && !isStopping) { 126: isInitialized = false 127: startFailed = false 128: startError = undefined 129: const crashError = new Error( 130: `LSP server ${serverName} crashed with exit code ${code}`, 131: ) 132: logError(crashError) 133: onCrash?.(crashError) 134: } 135: }) 136: process.stdin.on('error', (error: Error) => { 137: if (!isStopping) { 138: logForDebugging( 139: `LSP server ${serverName} stdin error: ${error.message}`, 140: ) 141: } 142: }) 143: const reader = new StreamMessageReader(process.stdout) 144: const writer = new StreamMessageWriter(process.stdin) 145: connection = createMessageConnection(reader, writer) 146: connection.onError(([error, _message, _code]) => { 147: if (!isStopping) { 148: startFailed = true 149: startError = error 150: logError( 151: new Error( 152: `LSP server ${serverName} connection error: ${error.message}`, 153: ), 154: ) 155: } 156: }) 157: connection.onClose(() => { 158: if (!isStopping) { 159: isInitialized = false 160: logForDebugging(`LSP server ${serverName} connection closed`) 161: } 162: }) 163: connection.listen() 164: connection 165: .trace(Trace.Verbose, { 166: log: (message: string) => { 167: logForDebugging(`[LSP PROTOCOL ${serverName}] ${message}`) 168: }, 169: }) 170: .catch((error: Error) => { 171: logForDebugging( 172: `Failed to enable tracing for ${serverName}: ${error.message}`, 173: ) 174: }) 175: for (const { method, handler } of pendingHandlers) { 176: connection.onNotification(method, handler) 177: logForDebugging( 178: `Applied queued notification handler for ${serverName}.${method}`, 179: ) 180: } 181: pendingHandlers.length = 0 182: for (const { method, handler } of pendingRequestHandlers) { 183: connection.onRequest(method, handler) 184: logForDebugging( 185: `Applied queued request handler for ${serverName}.${method}`, 186: ) 187: } 188: pendingRequestHandlers.length = 0 189: logForDebugging(`LSP client started for ${serverName}`) 190: } catch (error) { 191: const err = error as Error 192: logError( 193: new Error(`LSP server ${serverName} failed to start: ${err.message}`), 194: ) 195: throw error 196: } 197: }, 198: async initialize(params: InitializeParams): Promise<InitializeResult> { 199: if (!connection) { 200: throw new Error('LSP client not started') 201: } 202: checkStartFailed() 203: try { 204: const result: InitializeResult = await connection.sendRequest( 205: 'initialize', 206: params, 207: ) 208: capabilities = result.capabilities 209: await connection.sendNotification('initialized', {}) 210: isInitialized = true 211: logForDebugging(`LSP server ${serverName} initialized`) 212: return result 213: } catch (error) { 214: const err = error as Error 215: logError( 216: new Error( 217: `LSP server ${serverName} initialize failed: ${err.message}`, 218: ), 219: ) 220: throw error 221: } 222: }, 223: async sendRequest<TResult>( 224: method: string, 225: params: unknown, 226: ): Promise<TResult> { 227: if (!connection) { 228: throw new Error('LSP client not started') 229: } 230: checkStartFailed() 231: if (!isInitialized) { 232: throw new Error('LSP server not initialized') 233: } 234: try { 235: return await connection.sendRequest(method, params) 236: } catch (error) { 237: const err = error as Error 238: logError( 239: new Error( 240: `LSP server ${serverName} request ${method} failed: ${err.message}`, 241: ), 242: ) 243: throw error 244: } 245: }, 246: async sendNotification(method: string, params: unknown): Promise<void> { 247: if (!connection) { 248: throw new Error('LSP client not started') 249: } 250: checkStartFailed() 251: try { 252: await connection.sendNotification(method, params) 253: } catch (error) { 254: const err = error as Error 255: logError( 256: new Error( 257: `LSP server ${serverName} notification ${method} failed: ${err.message}`, 258: ), 259: ) 260: logForDebugging(`Notification ${method} failed but continuing`) 261: } 262: }, 263: onNotification(method: string, handler: (params: unknown) => void): void { 264: if (!connection) { 265: pendingHandlers.push({ method, handler }) 266: logForDebugging( 267: `Queued notification handler for ${serverName}.${method} (connection not ready)`, 268: ) 269: return 270: } 271: checkStartFailed() 272: connection.onNotification(method, handler) 273: }, 274: onRequest<TParams, TResult>( 275: method: string, 276: handler: (params: TParams) => TResult | Promise<TResult>, 277: ): void { 278: if (!connection) { 279: pendingRequestHandlers.push({ 280: method, 281: handler: handler as (params: unknown) => unknown | Promise<unknown>, 282: }) 283: logForDebugging( 284: `Queued request handler for ${serverName}.${method} (connection not ready)`, 285: ) 286: return 287: } 288: checkStartFailed() 289: connection.onRequest(method, handler) 290: }, 291: async stop(): Promise<void> { 292: let shutdownError: Error | undefined 293: isStopping = true 294: try { 295: if (connection) { 296: await connection.sendRequest('shutdown', {}) 297: await connection.sendNotification('exit', {}) 298: } 299: } catch (error) { 300: const err = error as Error 301: logError( 302: new Error(`LSP server ${serverName} stop failed: ${err.message}`), 303: ) 304: shutdownError = err 305: } finally { 306: if (connection) { 307: try { 308: connection.dispose() 309: } catch (error) { 310: logForDebugging( 311: `Connection disposal failed for ${serverName}: ${errorMessage(error)}`, 312: ) 313: } 314: connection = undefined 315: } 316: if (process) { 317: process.removeAllListeners('error') 318: process.removeAllListeners('exit') 319: if (process.stdin) { 320: process.stdin.removeAllListeners('error') 321: } 322: if (process.stderr) { 323: process.stderr.removeAllListeners('data') 324: } 325: try { 326: process.kill() 327: } catch (error) { 328: logForDebugging( 329: `Process kill failed for ${serverName} (may already be dead): ${errorMessage(error)}`, 330: ) 331: } 332: process = undefined 333: } 334: isInitialized = false 335: capabilities = undefined 336: isStopping = false 337: if (shutdownError) { 338: startFailed = true 339: startError = shutdownError 340: } 341: logForDebugging(`LSP client stopped for ${serverName}`) 342: } 343: if (shutdownError) { 344: throw shutdownError 345: } 346: }, 347: } 348: }

File: src/services/lsp/LSPDiagnosticRegistry.ts

typescript 1: import { randomUUID } from 'crypto' 2: import { LRUCache } from 'lru-cache' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { toError } from '../../utils/errors.js' 5: import { logError } from '../../utils/log.js' 6: import { jsonStringify } from '../../utils/slowOperations.js' 7: import type { DiagnosticFile } from '../diagnosticTracking.js' 8: export type PendingLSPDiagnostic = { 9: serverName: string 10: files: DiagnosticFile[] 11: timestamp: number 12: attachmentSent: boolean 13: } 14: const MAX_DIAGNOSTICS_PER_FILE = 10 15: const MAX_TOTAL_DIAGNOSTICS = 30 16: const MAX_DELIVERED_FILES = 500 17: const pendingDiagnostics = new Map<string, PendingLSPDiagnostic>() 18: const deliveredDiagnostics = new LRUCache<string, Set<string>>({ 19: max: MAX_DELIVERED_FILES, 20: }) 21: export function registerPendingLSPDiagnostic({ 22: serverName, 23: files, 24: }: { 25: serverName: string 26: files: DiagnosticFile[] 27: }): void { 28: const diagnosticId = randomUUID() 29: logForDebugging( 30: `LSP Diagnostics: Registering ${files.length} diagnostic file(s) from ${serverName} (ID: ${diagnosticId})`, 31: ) 32: pendingDiagnostics.set(diagnosticId, { 33: serverName, 34: files, 35: timestamp: Date.now(), 36: attachmentSent: false, 37: }) 38: } 39: function severityToNumber(severity: string | undefined): number { 40: switch (severity) { 41: case 'Error': 42: return 1 43: case 'Warning': 44: return 2 45: case 'Info': 46: return 3 47: case 'Hint': 48: return 4 49: default: 50: return 4 51: } 52: } 53: function createDiagnosticKey(diag: { 54: message: string 55: severity?: string 56: range?: unknown 57: source?: string 58: code?: unknown 59: }): string { 60: return jsonStringify({ 61: message: diag.message, 62: severity: diag.severity, 63: range: diag.range, 64: source: diag.source || null, 65: code: diag.code || null, 66: }) 67: } 68: function deduplicateDiagnosticFiles( 69: allFiles: DiagnosticFile[], 70: ): DiagnosticFile[] { 71: const fileMap = new Map<string, Set<string>>() 72: const dedupedFiles: DiagnosticFile[] = [] 73: for (const file of allFiles) { 74: if (!fileMap.has(file.uri)) { 75: fileMap.set(file.uri, new Set()) 76: dedupedFiles.push({ uri: file.uri, diagnostics: [] }) 77: } 78: const seenDiagnostics = fileMap.get(file.uri)! 79: const dedupedFile = dedupedFiles.find(f => f.uri === file.uri)! 80: const previouslyDelivered = deliveredDiagnostics.get(file.uri) || new Set() 81: for (const diag of file.diagnostics) { 82: try { 83: const key = createDiagnosticKey(diag) 84: if (seenDiagnostics.has(key) || previouslyDelivered.has(key)) { 85: continue 86: } 87: seenDiagnostics.add(key) 88: dedupedFile.diagnostics.push(diag) 89: } catch (error: unknown) { 90: const err = toError(error) 91: const truncatedMessage = 92: diag.message?.substring(0, 100) || '<no message>' 93: logError( 94: new Error( 95: `Failed to deduplicate diagnostic in ${file.uri}: ${err.message}. ` + 96: `Diagnostic message: ${truncatedMessage}`, 97: ), 98: ) 99: dedupedFile.diagnostics.push(diag) 100: } 101: } 102: } 103: return dedupedFiles.filter(f => f.diagnostics.length > 0) 104: } 105: export function checkForLSPDiagnostics(): Array<{ 106: serverName: string 107: files: DiagnosticFile[] 108: }> { 109: logForDebugging( 110: `LSP Diagnostics: Checking registry - ${pendingDiagnostics.size} pending`, 111: ) 112: const allFiles: DiagnosticFile[] = [] 113: const serverNames = new Set<string>() 114: const diagnosticsToMark: PendingLSPDiagnostic[] = [] 115: for (const diagnostic of pendingDiagnostics.values()) { 116: if (!diagnostic.attachmentSent) { 117: allFiles.push(...diagnostic.files) 118: serverNames.add(diagnostic.serverName) 119: diagnosticsToMark.push(diagnostic) 120: } 121: } 122: if (allFiles.length === 0) { 123: return [] 124: } 125: let dedupedFiles: DiagnosticFile[] 126: try { 127: dedupedFiles = deduplicateDiagnosticFiles(allFiles) 128: } catch (error: unknown) { 129: const err = toError(error) 130: logError(new Error(`Failed to deduplicate LSP diagnostics: ${err.message}`)) 131: dedupedFiles = allFiles 132: } 133: for (const diagnostic of diagnosticsToMark) { 134: diagnostic.attachmentSent = true 135: } 136: for (const [id, diagnostic] of pendingDiagnostics) { 137: if (diagnostic.attachmentSent) { 138: pendingDiagnostics.delete(id) 139: } 140: } 141: const originalCount = allFiles.reduce( 142: (sum, f) => sum + f.diagnostics.length, 143: 0, 144: ) 145: const dedupedCount = dedupedFiles.reduce( 146: (sum, f) => sum + f.diagnostics.length, 147: 0, 148: ) 149: if (originalCount > dedupedCount) { 150: logForDebugging( 151: `LSP Diagnostics: Deduplication removed ${originalCount - dedupedCount} duplicate diagnostic(s)`, 152: ) 153: } 154: let totalDiagnostics = 0 155: let truncatedCount = 0 156: for (const file of dedupedFiles) { 157: file.diagnostics.sort( 158: (a, b) => severityToNumber(a.severity) - severityToNumber(b.severity), 159: ) 160: if (file.diagnostics.length > MAX_DIAGNOSTICS_PER_FILE) { 161: truncatedCount += file.diagnostics.length - MAX_DIAGNOSTICS_PER_FILE 162: file.diagnostics = file.diagnostics.slice(0, MAX_DIAGNOSTICS_PER_FILE) 163: } 164: const remainingCapacity = MAX_TOTAL_DIAGNOSTICS - totalDiagnostics 165: if (file.diagnostics.length > remainingCapacity) { 166: truncatedCount += file.diagnostics.length - remainingCapacity 167: file.diagnostics = file.diagnostics.slice(0, remainingCapacity) 168: } 169: totalDiagnostics += file.diagnostics.length 170: } 171: dedupedFiles = dedupedFiles.filter(f => f.diagnostics.length > 0) 172: if (truncatedCount > 0) { 173: logForDebugging( 174: `LSP Diagnostics: Volume limiting removed ${truncatedCount} diagnostic(s) (max ${MAX_DIAGNOSTICS_PER_FILE}/file, ${MAX_TOTAL_DIAGNOSTICS} total)`, 175: ) 176: } 177: for (const file of dedupedFiles) { 178: if (!deliveredDiagnostics.has(file.uri)) { 179: deliveredDiagnostics.set(file.uri, new Set()) 180: } 181: const delivered = deliveredDiagnostics.get(file.uri)! 182: for (const diag of file.diagnostics) { 183: try { 184: delivered.add(createDiagnosticKey(diag)) 185: } catch (error: unknown) { 186: const err = toError(error) 187: const truncatedMessage = 188: diag.message?.substring(0, 100) || '<no message>' 189: logError( 190: new Error( 191: `Failed to track delivered diagnostic in ${file.uri}: ${err.message}. ` + 192: `Diagnostic message: ${truncatedMessage}`, 193: ), 194: ) 195: } 196: } 197: } 198: const finalCount = dedupedFiles.reduce( 199: (sum, f) => sum + f.diagnostics.length, 200: 0, 201: ) 202: if (finalCount === 0) { 203: logForDebugging( 204: `LSP Diagnostics: No new diagnostics to deliver (all filtered by deduplication)`, 205: ) 206: return [] 207: } 208: logForDebugging( 209: `LSP Diagnostics: Delivering ${dedupedFiles.length} file(s) with ${finalCount} diagnostic(s) from ${serverNames.size} server(s)`, 210: ) 211: return [ 212: { 213: serverName: Array.from(serverNames).join(', '), 214: files: dedupedFiles, 215: }, 216: ] 217: } 218: export function clearAllLSPDiagnostics(): void { 219: logForDebugging( 220: `LSP Diagnostics: Clearing ${pendingDiagnostics.size} pending diagnostic(s)`, 221: ) 222: pendingDiagnostics.clear() 223: } 224: export function resetAllLSPDiagnosticState(): void { 225: logForDebugging( 226: `LSP Diagnostics: Resetting all state (${pendingDiagnostics.size} pending, ${deliveredDiagnostics.size} files tracked)`, 227: ) 228: pendingDiagnostics.clear() 229: deliveredDiagnostics.clear() 230: } 231: export function clearDeliveredDiagnosticsForFile(fileUri: string): void { 232: if (deliveredDiagnostics.has(fileUri)) { 233: logForDebugging( 234: `LSP Diagnostics: Clearing delivered diagnostics for ${fileUri}`, 235: ) 236: deliveredDiagnostics.delete(fileUri) 237: } 238: } 239: export function getPendingLSPDiagnosticCount(): number { 240: return pendingDiagnostics.size 241: }

File: src/services/lsp/LSPServerInstance.ts

typescript 1: import * as path from 'path' 2: import { pathToFileURL } from 'url' 3: import type { InitializeParams } from 'vscode-languageserver-protocol' 4: import { getCwd } from '../../utils/cwd.js' 5: import { logForDebugging } from '../../utils/debug.js' 6: import { errorMessage } from '../../utils/errors.js' 7: import { logError } from '../../utils/log.js' 8: import { sleep } from '../../utils/sleep.js' 9: import type { createLSPClient as createLSPClientType } from './LSPClient.js' 10: import type { LspServerState, ScopedLspServerConfig } from './types.js' 11: const LSP_ERROR_CONTENT_MODIFIED = -32801 12: const MAX_RETRIES_FOR_TRANSIENT_ERRORS = 3 13: const RETRY_BASE_DELAY_MS = 500 14: export type LSPServerInstance = { 15: readonly name: string 16: readonly config: ScopedLspServerConfig 17: readonly state: LspServerState 18: readonly startTime: Date | undefined 19: readonly lastError: Error | undefined 20: readonly restartCount: number 21: start(): Promise<void> 22: stop(): Promise<void> 23: restart(): Promise<void> 24: isHealthy(): boolean 25: sendRequest<T>(method: string, params: unknown): Promise<T> 26: sendNotification(method: string, params: unknown): Promise<void> 27: onNotification(method: string, handler: (params: unknown) => void): void 28: onRequest<TParams, TResult>( 29: method: string, 30: handler: (params: TParams) => TResult | Promise<TResult>, 31: ): void 32: } 33: export function createLSPServerInstance( 34: name: string, 35: config: ScopedLspServerConfig, 36: ): LSPServerInstance { 37: if (config.restartOnCrash !== undefined) { 38: throw new Error( 39: `LSP server '${name}': restartOnCrash is not yet implemented. Remove this field from the configuration.`, 40: ) 41: } 42: if (config.shutdownTimeout !== undefined) { 43: throw new Error( 44: `LSP server '${name}': shutdownTimeout is not yet implemented. Remove this field from the configuration.`, 45: ) 46: } 47: const { createLSPClient } = require('./LSPClient.js') as { 48: createLSPClient: typeof createLSPClientType 49: } 50: let state: LspServerState = 'stopped' 51: let startTime: Date | undefined 52: let lastError: Error | undefined 53: let restartCount = 0 54: let crashRecoveryCount = 0 55: const client = createLSPClient(name, error => { 56: state = 'error' 57: lastError = error 58: crashRecoveryCount++ 59: }) 60: async function start(): Promise<void> { 61: if (state === 'running' || state === 'starting') { 62: return 63: } 64: const maxRestarts = config.maxRestarts ?? 3 65: if (state === 'error' && crashRecoveryCount > maxRestarts) { 66: const error = new Error( 67: `LSP server '${name}' exceeded max crash recovery attempts (${maxRestarts})`, 68: ) 69: lastError = error 70: logError(error) 71: throw error 72: } 73: let initPromise: Promise<unknown> | undefined 74: try { 75: state = 'starting' 76: logForDebugging(`Starting LSP server instance: ${name}`) 77: await client.start(config.command, config.args || [], { 78: env: config.env, 79: cwd: config.workspaceFolder, 80: }) 81: const workspaceFolder = config.workspaceFolder || getCwd() 82: const workspaceUri = pathToFileURL(workspaceFolder).href 83: const initParams: InitializeParams = { 84: processId: process.pid, 85: initializationOptions: config.initializationOptions ?? {}, 86: workspaceFolders: [ 87: { 88: uri: workspaceUri, 89: name: path.basename(workspaceFolder), 90: }, 91: ], 92: rootPath: workspaceFolder, 93: rootUri: workspaceUri, 94: capabilities: { 95: workspace: { 96: configuration: false, 97: workspaceFolders: false, 98: }, 99: textDocument: { 100: synchronization: { 101: dynamicRegistration: false, 102: willSave: false, 103: willSaveWaitUntil: false, 104: didSave: true, 105: }, 106: publishDiagnostics: { 107: relatedInformation: true, 108: tagSupport: { 109: valueSet: [1, 2], 110: }, 111: versionSupport: false, 112: codeDescriptionSupport: true, 113: dataSupport: false, 114: }, 115: hover: { 116: dynamicRegistration: false, 117: contentFormat: ['markdown', 'plaintext'], 118: }, 119: definition: { 120: dynamicRegistration: false, 121: linkSupport: true, 122: }, 123: references: { 124: dynamicRegistration: false, 125: }, 126: documentSymbol: { 127: dynamicRegistration: false, 128: hierarchicalDocumentSymbolSupport: true, 129: }, 130: callHierarchy: { 131: dynamicRegistration: false, 132: }, 133: }, 134: general: { 135: positionEncodings: ['utf-16'], 136: }, 137: }, 138: } 139: initPromise = client.initialize(initParams) 140: if (config.startupTimeout !== undefined) { 141: await withTimeout( 142: initPromise, 143: config.startupTimeout, 144: `LSP server '${name}' timed out after ${config.startupTimeout}ms during initialization`, 145: ) 146: } else { 147: await initPromise 148: } 149: state = 'running' 150: startTime = new Date() 151: crashRecoveryCount = 0 152: logForDebugging(`LSP server instance started: ${name}`) 153: } catch (error) { 154: client.stop().catch(() => {}) 155: initPromise?.catch(() => {}) 156: state = 'error' 157: lastError = error as Error 158: logError(error) 159: throw error 160: } 161: } 162: async function stop(): Promise<void> { 163: if (state === 'stopped' || state === 'stopping') { 164: return 165: } 166: try { 167: state = 'stopping' 168: await client.stop() 169: state = 'stopped' 170: logForDebugging(`LSP server instance stopped: ${name}`) 171: } catch (error) { 172: state = 'error' 173: lastError = error as Error 174: logError(error) 175: throw error 176: } 177: } 178: async function restart(): Promise<void> { 179: try { 180: await stop() 181: } catch (error) { 182: const stopError = new Error( 183: `Failed to stop LSP server '${name}' during restart: ${errorMessage(error)}`, 184: ) 185: logError(stopError) 186: throw stopError 187: } 188: restartCount++ 189: const maxRestarts = config.maxRestarts ?? 3 190: if (restartCount > maxRestarts) { 191: const error = new Error( 192: `Max restart attempts (${maxRestarts}) exceeded for server '${name}'`, 193: ) 194: logError(error) 195: throw error 196: } 197: try { 198: await start() 199: } catch (error) { 200: const startError = new Error( 201: `Failed to start LSP server '${name}' during restart (attempt ${restartCount}/${maxRestarts}): ${errorMessage(error)}`, 202: ) 203: logError(startError) 204: throw startError 205: } 206: } 207: function isHealthy(): boolean { 208: return state === 'running' && client.isInitialized 209: } 210: async function sendRequest<T>(method: string, params: unknown): Promise<T> { 211: if (!isHealthy()) { 212: const error = new Error( 213: `Cannot send request to LSP server '${name}': server is ${state}` + 214: `${lastError ? `, last error: ${lastError.message}` : ''}`, 215: ) 216: logError(error) 217: throw error 218: } 219: let lastAttemptError: Error | undefined 220: for ( 221: let attempt = 0; 222: attempt <= MAX_RETRIES_FOR_TRANSIENT_ERRORS; 223: attempt++ 224: ) { 225: try { 226: return await client.sendRequest(method, params) 227: } catch (error) { 228: lastAttemptError = error as Error 229: const errorCode = (error as { code?: number }).code 230: const isContentModifiedError = 231: typeof errorCode === 'number' && 232: errorCode === LSP_ERROR_CONTENT_MODIFIED 233: if ( 234: isContentModifiedError && 235: attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS 236: ) { 237: const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt) 238: logForDebugging( 239: `LSP request '${method}' to '${name}' got ContentModified error, ` + 240: `retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`, 241: ) 242: await sleep(delay) 243: continue 244: } 245: break 246: } 247: } 248: const requestError = new Error( 249: `LSP request '${method}' failed for server '${name}': ${lastAttemptError?.message ?? 'unknown error'}`, 250: ) 251: logError(requestError) 252: throw requestError 253: } 254: async function sendNotification( 255: method: string, 256: params: unknown, 257: ): Promise<void> { 258: if (!isHealthy()) { 259: const error = new Error( 260: `Cannot send notification to LSP server '${name}': server is ${state}`, 261: ) 262: logError(error) 263: throw error 264: } 265: try { 266: await client.sendNotification(method, params) 267: } catch (error) { 268: const notificationError = new Error( 269: `LSP notification '${method}' failed for server '${name}': ${errorMessage(error)}`, 270: ) 271: logError(notificationError) 272: throw notificationError 273: } 274: } 275: function onNotification( 276: method: string, 277: handler: (params: unknown) => void, 278: ): void { 279: client.onNotification(method, handler) 280: } 281: function onRequest<TParams, TResult>( 282: method: string, 283: handler: (params: TParams) => TResult | Promise<TResult>, 284: ): void { 285: client.onRequest(method, handler) 286: } 287: return { 288: name, 289: config, 290: get state() { 291: return state 292: }, 293: get startTime() { 294: return startTime 295: }, 296: get lastError() { 297: return lastError 298: }, 299: get restartCount() { 300: return restartCount 301: }, 302: start, 303: stop, 304: restart, 305: isHealthy, 306: sendRequest, 307: sendNotification, 308: onNotification, 309: onRequest, 310: } 311: } 312: function withTimeout<T>( 313: promise: Promise<T>, 314: ms: number, 315: message: string, 316: ): Promise<T> { 317: let timer: ReturnType<typeof setTimeout> 318: const timeoutPromise = new Promise<never>((_, reject) => { 319: timer = setTimeout((rej, msg) => rej(new Error(msg)), ms, reject, message) 320: }) 321: return Promise.race([promise, timeoutPromise]).finally(() => 322: clearTimeout(timer!), 323: ) 324: }

File: src/services/lsp/LSPServerManager.ts

typescript 1: import * as path from 'path' 2: import { pathToFileURL } from 'url' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { errorMessage } from '../../utils/errors.js' 5: import { logError } from '../../utils/log.js' 6: import { getAllLspServers } from './config.js' 7: import { 8: createLSPServerInstance, 9: type LSPServerInstance, 10: } from './LSPServerInstance.js' 11: import type { ScopedLspServerConfig } from './types.js' 12: export type LSPServerManager = { 13: initialize(): Promise<void> 14: shutdown(): Promise<void> 15: getServerForFile(filePath: string): LSPServerInstance | undefined 16: ensureServerStarted(filePath: string): Promise<LSPServerInstance | undefined> 17: sendRequest<T>( 18: filePath: string, 19: method: string, 20: params: unknown, 21: ): Promise<T | undefined> 22: getAllServers(): Map<string, LSPServerInstance> 23: openFile(filePath: string, content: string): Promise<void> 24: changeFile(filePath: string, content: string): Promise<void> 25: saveFile(filePath: string): Promise<void> 26: closeFile(filePath: string): Promise<void> 27: isFileOpen(filePath: string): boolean 28: } 29: export function createLSPServerManager(): LSPServerManager { 30: const servers: Map<string, LSPServerInstance> = new Map() 31: const extensionMap: Map<string, string[]> = new Map() 32: const openedFiles: Map<string, string> = new Map() 33: async function initialize(): Promise<void> { 34: let serverConfigs: Record<string, ScopedLspServerConfig> 35: try { 36: const result = await getAllLspServers() 37: serverConfigs = result.servers 38: logForDebugging( 39: `[LSP SERVER MANAGER] getAllLspServers returned ${Object.keys(serverConfigs).length} server(s)`, 40: ) 41: } catch (error) { 42: const err = error as Error 43: logError( 44: new Error(`Failed to load LSP server configuration: ${err.message}`), 45: ) 46: throw error 47: } 48: for (const [serverName, config] of Object.entries(serverConfigs)) { 49: try { 50: if (!config.command) { 51: throw new Error( 52: `Server ${serverName} missing required 'command' field`, 53: ) 54: } 55: if ( 56: !config.extensionToLanguage || 57: Object.keys(config.extensionToLanguage).length === 0 58: ) { 59: throw new Error( 60: `Server ${serverName} missing required 'extensionToLanguage' field`, 61: ) 62: } 63: const fileExtensions = Object.keys(config.extensionToLanguage) 64: for (const ext of fileExtensions) { 65: const normalized = ext.toLowerCase() 66: if (!extensionMap.has(normalized)) { 67: extensionMap.set(normalized, []) 68: } 69: const serverList = extensionMap.get(normalized) 70: if (serverList) { 71: serverList.push(serverName) 72: } 73: } 74: const instance = createLSPServerInstance(serverName, config) 75: servers.set(serverName, instance) 76: instance.onRequest( 77: 'workspace/configuration', 78: (params: { items: Array<{ section?: string }> }) => { 79: logForDebugging( 80: `LSP: Received workspace/configuration request from ${serverName}`, 81: ) 82: return params.items.map(() => null) 83: }, 84: ) 85: } catch (error) { 86: const err = error as Error 87: logError( 88: new Error( 89: `Failed to initialize LSP server ${serverName}: ${err.message}`, 90: ), 91: ) 92: } 93: } 94: logForDebugging(`LSP manager initialized with ${servers.size} servers`) 95: } 96: async function shutdown(): Promise<void> { 97: const toStop = Array.from(servers.entries()).filter( 98: ([, s]) => s.state === 'running' || s.state === 'error', 99: ) 100: const results = await Promise.allSettled( 101: toStop.map(([, server]) => server.stop()), 102: ) 103: servers.clear() 104: extensionMap.clear() 105: openedFiles.clear() 106: const errors = results 107: .map((r, i) => 108: r.status === 'rejected' 109: ? `${toStop[i]![0]}: ${errorMessage(r.reason)}` 110: : null, 111: ) 112: .filter((e): e is string => e !== null) 113: if (errors.length > 0) { 114: const err = new Error( 115: `Failed to stop ${errors.length} LSP server(s): ${errors.join('; ')}`, 116: ) 117: logError(err) 118: throw err 119: } 120: } 121: function getServerForFile(filePath: string): LSPServerInstance | undefined { 122: const ext = path.extname(filePath).toLowerCase() 123: const serverNames = extensionMap.get(ext) 124: if (!serverNames || serverNames.length === 0) { 125: return undefined 126: } 127: const serverName = serverNames[0] 128: if (!serverName) { 129: return undefined 130: } 131: return servers.get(serverName) 132: } 133: async function ensureServerStarted( 134: filePath: string, 135: ): Promise<LSPServerInstance | undefined> { 136: const server = getServerForFile(filePath) 137: if (!server) return undefined 138: if (server.state === 'stopped' || server.state === 'error') { 139: try { 140: await server.start() 141: } catch (error) { 142: const err = error as Error 143: logError( 144: new Error( 145: `Failed to start LSP server for file ${filePath}: ${err.message}`, 146: ), 147: ) 148: throw error 149: } 150: } 151: return server 152: } 153: async function sendRequest<T>( 154: filePath: string, 155: method: string, 156: params: unknown, 157: ): Promise<T | undefined> { 158: const server = await ensureServerStarted(filePath) 159: if (!server) return undefined 160: try { 161: return await server.sendRequest<T>(method, params) 162: } catch (error) { 163: const err = error as Error 164: logError( 165: new Error( 166: `LSP request failed for file ${filePath}, method '${method}': ${err.message}`, 167: ), 168: ) 169: throw error 170: } 171: } 172: function getAllServers(): Map<string, LSPServerInstance> { 173: return servers 174: } 175: async function openFile(filePath: string, content: string): Promise<void> { 176: const server = await ensureServerStarted(filePath) 177: if (!server) return 178: const fileUri = pathToFileURL(path.resolve(filePath)).href 179: if (openedFiles.get(fileUri) === server.name) { 180: logForDebugging( 181: `LSP: File already open, skipping didOpen for ${filePath}`, 182: ) 183: return 184: } 185: const ext = path.extname(filePath).toLowerCase() 186: const languageId = server.config.extensionToLanguage[ext] || 'plaintext' 187: try { 188: await server.sendNotification('textDocument/didOpen', { 189: textDocument: { 190: uri: fileUri, 191: languageId, 192: version: 1, 193: text: content, 194: }, 195: }) 196: openedFiles.set(fileUri, server.name) 197: logForDebugging( 198: `LSP: Sent didOpen for ${filePath} (languageId: ${languageId})`, 199: ) 200: } catch (error) { 201: const err = new Error( 202: `Failed to sync file open ${filePath}: ${errorMessage(error)}`, 203: ) 204: logError(err) 205: throw err 206: } 207: } 208: async function changeFile(filePath: string, content: string): Promise<void> { 209: const server = getServerForFile(filePath) 210: if (!server || server.state !== 'running') { 211: return openFile(filePath, content) 212: } 213: const fileUri = pathToFileURL(path.resolve(filePath)).href 214: if (openedFiles.get(fileUri) !== server.name) { 215: return openFile(filePath, content) 216: } 217: try { 218: await server.sendNotification('textDocument/didChange', { 219: textDocument: { 220: uri: fileUri, 221: version: 1, 222: }, 223: contentChanges: [{ text: content }], 224: }) 225: logForDebugging(`LSP: Sent didChange for ${filePath}`) 226: } catch (error) { 227: const err = new Error( 228: `Failed to sync file change ${filePath}: ${errorMessage(error)}`, 229: ) 230: logError(err) 231: throw err 232: } 233: } 234: async function saveFile(filePath: string): Promise<void> { 235: const server = getServerForFile(filePath) 236: if (!server || server.state !== 'running') return 237: try { 238: await server.sendNotification('textDocument/didSave', { 239: textDocument: { 240: uri: pathToFileURL(path.resolve(filePath)).href, 241: }, 242: }) 243: logForDebugging(`LSP: Sent didSave for ${filePath}`) 244: } catch (error) { 245: const err = new Error( 246: `Failed to sync file save ${filePath}: ${errorMessage(error)}`, 247: ) 248: logError(err) 249: throw err 250: } 251: } 252: async function closeFile(filePath: string): Promise<void> { 253: const server = getServerForFile(filePath) 254: if (!server || server.state !== 'running') return 255: const fileUri = pathToFileURL(path.resolve(filePath)).href 256: try { 257: await server.sendNotification('textDocument/didClose', { 258: textDocument: { 259: uri: fileUri, 260: }, 261: }) 262: openedFiles.delete(fileUri) 263: logForDebugging(`LSP: Sent didClose for ${filePath}`) 264: } catch (error) { 265: const err = new Error( 266: `Failed to sync file close ${filePath}: ${errorMessage(error)}`, 267: ) 268: logError(err) 269: throw err 270: } 271: } 272: function isFileOpen(filePath: string): boolean { 273: const fileUri = pathToFileURL(path.resolve(filePath)).href 274: return openedFiles.has(fileUri) 275: } 276: return { 277: initialize, 278: shutdown, 279: getServerForFile, 280: ensureServerStarted, 281: sendRequest, 282: getAllServers, 283: openFile, 284: changeFile, 285: saveFile, 286: closeFile, 287: isFileOpen, 288: } 289: }

File: src/services/lsp/manager.ts

typescript 1: import { logForDebugging } from '../../utils/debug.js' 2: import { isBareMode } from '../../utils/envUtils.js' 3: import { errorMessage } from '../../utils/errors.js' 4: import { logError } from '../../utils/log.js' 5: import { 6: createLSPServerManager, 7: type LSPServerManager, 8: } from './LSPServerManager.js' 9: import { registerLSPNotificationHandlers } from './passiveFeedback.js' 10: type InitializationState = 'not-started' | 'pending' | 'success' | 'failed' 11: let lspManagerInstance: LSPServerManager | undefined 12: let initializationState: InitializationState = 'not-started' 13: let initializationError: Error | undefined 14: let initializationGeneration = 0 15: let initializationPromise: Promise<void> | undefined 16: export function _resetLspManagerForTesting(): void { 17: initializationState = 'not-started' 18: initializationError = undefined 19: initializationPromise = undefined 20: initializationGeneration++ 21: } 22: export function getLspServerManager(): LSPServerManager | undefined { 23: if (initializationState === 'failed') { 24: return undefined 25: } 26: return lspManagerInstance 27: } 28: export function getInitializationStatus(): 29: | { status: 'not-started' } 30: | { status: 'pending' } 31: | { status: 'success' } 32: | { status: 'failed'; error: Error } { 33: if (initializationState === 'failed') { 34: return { 35: status: 'failed', 36: error: initializationError || new Error('Initialization failed'), 37: } 38: } 39: if (initializationState === 'not-started') { 40: return { status: 'not-started' } 41: } 42: if (initializationState === 'pending') { 43: return { status: 'pending' } 44: } 45: return { status: 'success' } 46: } 47: export function isLspConnected(): boolean { 48: if (initializationState === 'failed') return false 49: const manager = getLspServerManager() 50: if (!manager) return false 51: const servers = manager.getAllServers() 52: if (servers.size === 0) return false 53: for (const server of servers.values()) { 54: if (server.state !== 'error') return true 55: } 56: return false 57: } 58: export async function waitForInitialization(): Promise<void> { 59: if (initializationState === 'success' || initializationState === 'failed') { 60: return 61: } 62: if (initializationState === 'pending' && initializationPromise) { 63: await initializationPromise 64: } 65: } 66: export function initializeLspServerManager(): void { 67: if (isBareMode()) { 68: return 69: } 70: logForDebugging('[LSP MANAGER] initializeLspServerManager() called') 71: if (lspManagerInstance !== undefined && initializationState !== 'failed') { 72: logForDebugging( 73: '[LSP MANAGER] Already initialized or initializing, skipping', 74: ) 75: return 76: } 77: if (initializationState === 'failed') { 78: lspManagerInstance = undefined 79: initializationError = undefined 80: } 81: lspManagerInstance = createLSPServerManager() 82: initializationState = 'pending' 83: logForDebugging('[LSP MANAGER] Created manager instance, state=pending') 84: const currentGeneration = ++initializationGeneration 85: logForDebugging( 86: `[LSP MANAGER] Starting async initialization (generation ${currentGeneration})`, 87: ) 88: initializationPromise = lspManagerInstance 89: .initialize() 90: .then(() => { 91: if (currentGeneration === initializationGeneration) { 92: initializationState = 'success' 93: logForDebugging('LSP server manager initialized successfully') 94: if (lspManagerInstance) { 95: registerLSPNotificationHandlers(lspManagerInstance) 96: } 97: } 98: }) 99: .catch((error: unknown) => { 100: if (currentGeneration === initializationGeneration) { 101: initializationState = 'failed' 102: initializationError = error as Error 103: lspManagerInstance = undefined 104: logError(error as Error) 105: logForDebugging( 106: `Failed to initialize LSP server manager: ${errorMessage(error)}`, 107: ) 108: } 109: }) 110: } 111: export function reinitializeLspServerManager(): void { 112: if (initializationState === 'not-started') { 113: return 114: } 115: logForDebugging('[LSP MANAGER] reinitializeLspServerManager() called') 116: if (lspManagerInstance) { 117: void lspManagerInstance.shutdown().catch(err => { 118: logForDebugging( 119: `[LSP MANAGER] old instance shutdown during reinit failed: ${errorMessage(err)}`, 120: ) 121: }) 122: } 123: lspManagerInstance = undefined 124: initializationState = 'not-started' 125: initializationError = undefined 126: initializeLspServerManager() 127: } 128: export async function shutdownLspServerManager(): Promise<void> { 129: if (lspManagerInstance === undefined) { 130: return 131: } 132: try { 133: await lspManagerInstance.shutdown() 134: logForDebugging('LSP server manager shut down successfully') 135: } catch (error: unknown) { 136: logError(error as Error) 137: logForDebugging( 138: `Failed to shutdown LSP server manager: ${errorMessage(error)}`, 139: ) 140: } finally { 141: lspManagerInstance = undefined 142: initializationState = 'not-started' 143: initializationError = undefined 144: initializationPromise = undefined 145: initializationGeneration++ 146: } 147: }

File: src/services/lsp/passiveFeedback.ts

typescript 1: import { fileURLToPath } from 'url' 2: import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol' 3: import { logForDebugging } from '../../utils/debug.js' 4: import { toError } from '../../utils/errors.js' 5: import { logError } from '../../utils/log.js' 6: import { jsonStringify } from '../../utils/slowOperations.js' 7: import type { DiagnosticFile } from '../diagnosticTracking.js' 8: import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js' 9: import type { LSPServerManager } from './LSPServerManager.js' 10: function mapLSPSeverity( 11: lspSeverity: number | undefined, 12: ): 'Error' | 'Warning' | 'Info' | 'Hint' { 13: switch (lspSeverity) { 14: case 1: 15: return 'Error' 16: case 2: 17: return 'Warning' 18: case 3: 19: return 'Info' 20: case 4: 21: return 'Hint' 22: default: 23: return 'Error' 24: } 25: } 26: export function formatDiagnosticsForAttachment( 27: params: PublishDiagnosticsParams, 28: ): DiagnosticFile[] { 29: let uri: string 30: try { 31: uri = params.uri.startsWith('file://') 32: ? fileURLToPath(params.uri) 33: : params.uri 34: } catch (error) { 35: const err = toError(error) 36: logError(err) 37: logForDebugging( 38: `Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`, 39: ) 40: uri = params.uri 41: } 42: const diagnostics = params.diagnostics.map( 43: (diag: { 44: message: string 45: severity?: number 46: range: { 47: start: { line: number; character: number } 48: end: { line: number; character: number } 49: } 50: source?: string 51: code?: string | number 52: }) => ({ 53: message: diag.message, 54: severity: mapLSPSeverity(diag.severity), 55: range: { 56: start: { 57: line: diag.range.start.line, 58: character: diag.range.start.character, 59: }, 60: end: { 61: line: diag.range.end.line, 62: character: diag.range.end.character, 63: }, 64: }, 65: source: diag.source, 66: code: 67: diag.code !== undefined && diag.code !== null 68: ? String(diag.code) 69: : undefined, 70: }), 71: ) 72: return [ 73: { 74: uri, 75: diagnostics, 76: }, 77: ] 78: } 79: export type HandlerRegistrationResult = { 80: totalServers: number 81: successCount: number 82: registrationErrors: Array<{ serverName: string; error: string }> 83: diagnosticFailures: Map<string, { count: number; lastError: string }> 84: } 85: export function registerLSPNotificationHandlers( 86: manager: LSPServerManager, 87: ): HandlerRegistrationResult { 88: const servers = manager.getAllServers() 89: const registrationErrors: Array<{ serverName: string; error: string }> = [] 90: let successCount = 0 91: const diagnosticFailures: Map<string, { count: number; lastError: string }> = 92: new Map() 93: for (const [serverName, serverInstance] of servers.entries()) { 94: try { 95: if ( 96: !serverInstance || 97: typeof serverInstance.onNotification !== 'function' 98: ) { 99: const errorMsg = !serverInstance 100: ? 'Server instance is null/undefined' 101: : 'Server instance has no onNotification method' 102: registrationErrors.push({ serverName, error: errorMsg }) 103: const err = new Error(`${errorMsg} for ${serverName}`) 104: logError(err) 105: logForDebugging( 106: `Skipping handler registration for ${serverName}: ${errorMsg}`, 107: ) 108: continue 109: } 110: serverInstance.onNotification( 111: 'textDocument/publishDiagnostics', 112: (params: unknown) => { 113: logForDebugging( 114: `[PASSIVE DIAGNOSTICS] Handler invoked for ${serverName}! Params type: ${typeof params}`, 115: ) 116: try { 117: if ( 118: !params || 119: typeof params !== 'object' || 120: !('uri' in params) || 121: !('diagnostics' in params) 122: ) { 123: const err = new Error( 124: `LSP server ${serverName} sent invalid diagnostic params (missing uri or diagnostics)`, 125: ) 126: logError(err) 127: logForDebugging( 128: `Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`, 129: ) 130: return 131: } 132: const diagnosticParams = params as PublishDiagnosticsParams 133: logForDebugging( 134: `Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`, 135: ) 136: const diagnosticFiles = 137: formatDiagnosticsForAttachment(diagnosticParams) 138: const firstFile = diagnosticFiles[0] 139: if ( 140: !firstFile || 141: diagnosticFiles.length === 0 || 142: firstFile.diagnostics.length === 0 143: ) { 144: logForDebugging( 145: `Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`, 146: ) 147: return 148: } 149: try { 150: registerPendingLSPDiagnostic({ 151: serverName, 152: files: diagnosticFiles, 153: }) 154: logForDebugging( 155: `LSP Diagnostics: Registered ${diagnosticFiles.length} diagnostic file(s) from ${serverName} for async delivery`, 156: ) 157: diagnosticFailures.delete(serverName) 158: } catch (error) { 159: const err = toError(error) 160: logError(err) 161: logForDebugging( 162: `Error registering LSP diagnostics from ${serverName}: ` + 163: `URI: ${diagnosticParams.uri}, ` + 164: `Diagnostic count: ${firstFile.diagnostics.length}, ` + 165: `Error: ${err.message}`, 166: ) 167: const failures = diagnosticFailures.get(serverName) || { 168: count: 0, 169: lastError: '', 170: } 171: failures.count++ 172: failures.lastError = err.message 173: diagnosticFailures.set(serverName, failures) 174: if (failures.count >= 3) { 175: logForDebugging( 176: `WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` + 177: `Last error: ${failures.lastError}. ` + 178: `This may indicate a problem with the LSP server or diagnostic processing. ` + 179: `Check logs for details.`, 180: ) 181: } 182: } 183: } catch (error) { 184: // Catch any unexpected errors from the entire handler to prevent breaking the notification loop 185: const err = toError(error) 186: logError(err) 187: logForDebugging( 188: `Unexpected error processing diagnostics from ${serverName}: ${err.message}`, 189: ) 190: // Track consecutive failures and warn after 3+ 191: const failures = diagnosticFailures.get(serverName) || { 192: count: 0, 193: lastError: '', 194: } 195: failures.count++ 196: failures.lastError = err.message 197: diagnosticFailures.set(serverName, failures) 198: if (failures.count >= 3) { 199: logForDebugging( 200: `WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` + 201: `Last error: ${failures.lastError}. ` + 202: `This may indicate a problem with the LSP server or diagnostic processing. ` + 203: `Check logs for details.`, 204: ) 205: } 206: // Don't re-throw - isolate errors to this server only 207: } 208: }, 209: ) 210: logForDebugging(`Registered diagnostics handler for ${serverName}`) 211: successCount++ 212: } catch (error) { 213: const err = toError(error) 214: registrationErrors.push({ 215: serverName, 216: error: err.message, 217: }) 218: logError(err) 219: logForDebugging( 220: `Failed to register diagnostics handler for ${serverName}: ` + 221: `Error: ${err.message}`, 222: ) 223: } 224: } 225: const totalServers = servers.size 226: if (registrationErrors.length > 0) { 227: const failedServers = registrationErrors 228: .map(e => `${e.serverName} (${e.error})`) 229: .join(', ') 230: logError( 231: new Error( 232: `Failed to register diagnostics for ${registrationErrors.length} LSP server(s): ${failedServers}`, 233: ), 234: ) 235: logForDebugging( 236: `LSP notification handler registration: ${successCount}/${totalServers} succeeded. ` + 237: `Failed servers: ${failedServers}. ` + 238: `Diagnostics from failed servers will not be delivered.`, 239: ) 240: } else { 241: logForDebugging( 242: `LSP notification handlers registered successfully for all ${totalServers} server(s)`, 243: ) 244: } 245: return { 246: totalServers, 247: successCount, 248: registrationErrors, 249: diagnosticFailures, 250: } 251: }

File: src/services/MagicDocs/magicDocs.ts

typescript 1: import type { Tool, ToolUseContext } from '../../Tool.js' 2: import type { BuiltInAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 3: import { runAgent } from '../../tools/AgentTool/runAgent.js' 4: import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 5: import { 6: FileReadTool, 7: type Output as FileReadToolOutput, 8: registerFileReadListener, 9: } from '../../tools/FileReadTool/FileReadTool.js' 10: import { isFsInaccessible } from '../../utils/errors.js' 11: import { cloneFileStateCache } from '../../utils/fileStateCache.js' 12: import { 13: type REPLHookContext, 14: registerPostSamplingHook, 15: } from '../../utils/hooks/postSamplingHooks.js' 16: import { 17: createUserMessage, 18: hasToolCallsInLastAssistantTurn, 19: } from '../../utils/messages.js' 20: import { sequential } from '../../utils/sequential.js' 21: import { buildMagicDocsUpdatePrompt } from './prompts.js' 22: const MAGIC_DOC_HEADER_PATTERN = /^#\s*MAGIC\s+DOC:\s*(.+)$/im 23: const ITALICS_PATTERN = /^[_*](.+?)[_*]\s*$/m 24: type MagicDocInfo = { 25: path: string 26: } 27: const trackedMagicDocs = new Map<string, MagicDocInfo>() 28: export function clearTrackedMagicDocs(): void { 29: trackedMagicDocs.clear() 30: } 31: export function detectMagicDocHeader( 32: content: string, 33: ): { title: string; instructions?: string } | null { 34: const match = content.match(MAGIC_DOC_HEADER_PATTERN) 35: if (!match || !match[1]) { 36: return null 37: } 38: const title = match[1].trim() 39: const headerEndIndex = match.index! + match[0].length 40: const afterHeader = content.slice(headerEndIndex) 41: const nextLineMatch = afterHeader.match(/^\s*\n(?:\s*\n)?(.+?)(?:\n|$)/) 42: if (nextLineMatch && nextLineMatch[1]) { 43: const nextLine = nextLineMatch[1] 44: const italicsMatch = nextLine.match(ITALICS_PATTERN) 45: if (italicsMatch && italicsMatch[1]) { 46: const instructions = italicsMatch[1].trim() 47: return { 48: title, 49: instructions, 50: } 51: } 52: } 53: return { title } 54: } 55: export function registerMagicDoc(filePath: string): void { 56: if (!trackedMagicDocs.has(filePath)) { 57: trackedMagicDocs.set(filePath, { 58: path: filePath, 59: }) 60: } 61: } 62: function getMagicDocsAgent(): BuiltInAgentDefinition { 63: return { 64: agentType: 'magic-docs', 65: whenToUse: 'Update Magic Docs', 66: tools: [FILE_EDIT_TOOL_NAME], 67: model: 'sonnet', 68: source: 'built-in', 69: baseDir: 'built-in', 70: getSystemPrompt: () => '', // Will use override systemPrompt 71: } 72: } 73: /** 74: * Update a single Magic Doc 75: */ 76: async function updateMagicDoc( 77: docInfo: MagicDocInfo, 78: context: REPLHookContext, 79: ): Promise<void> { 80: const { messages, systemPrompt, userContext, systemContext, toolUseContext } = 81: context 82: // Clone the FileStateCache to isolate Magic Docs operations. Delete this 83: // doc's entry so FileReadTool's dedup doesn't return a file_unchanged 84: const clonedReadFileState = cloneFileStateCache(toolUseContext.readFileState) 85: clonedReadFileState.delete(docInfo.path) 86: const clonedToolUseContext: ToolUseContext = { 87: ...toolUseContext, 88: readFileState: clonedReadFileState, 89: } 90: let currentDoc = '' 91: try { 92: const result = await FileReadTool.call( 93: { file_path: docInfo.path }, 94: clonedToolUseContext, 95: ) 96: const output = result.data as FileReadToolOutput 97: if (output.type === 'text') { 98: currentDoc = output.file.content 99: } 100: } catch (e: unknown) { 101: if ( 102: isFsInaccessible(e) || 103: (e instanceof Error && e.message.startsWith('File does not exist')) 104: ) { 105: trackedMagicDocs.delete(docInfo.path) 106: return 107: } 108: throw e 109: } 110: const detected = detectMagicDocHeader(currentDoc) 111: if (!detected) { 112: trackedMagicDocs.delete(docInfo.path) 113: return 114: } 115: const userPrompt = await buildMagicDocsUpdatePrompt( 116: currentDoc, 117: docInfo.path, 118: detected.title, 119: detected.instructions, 120: ) 121: const canUseTool = async (tool: Tool, input: unknown) => { 122: if ( 123: tool.name === FILE_EDIT_TOOL_NAME && 124: typeof input === 'object' && 125: input !== null && 126: 'file_path' in input 127: ) { 128: const filePath = input.file_path 129: if (typeof filePath === 'string' && filePath === docInfo.path) { 130: return { behavior: 'allow' as const, updatedInput: input } 131: } 132: } 133: return { 134: behavior: 'deny' as const, 135: message: `only ${FILE_EDIT_TOOL_NAME} is allowed for ${docInfo.path}`, 136: decisionReason: { 137: type: 'other' as const, 138: reason: `only ${FILE_EDIT_TOOL_NAME} is allowed`, 139: }, 140: } 141: } 142: for await (const _message of runAgent({ 143: agentDefinition: getMagicDocsAgent(), 144: promptMessages: [createUserMessage({ content: userPrompt })], 145: toolUseContext: clonedToolUseContext, 146: canUseTool, 147: isAsync: true, 148: forkContextMessages: messages, 149: querySource: 'magic_docs', 150: override: { 151: systemPrompt, 152: userContext, 153: systemContext, 154: }, 155: availableTools: clonedToolUseContext.options.tools, 156: })) { 157: } 158: } 159: const updateMagicDocs = sequential(async function ( 160: context: REPLHookContext, 161: ): Promise<void> { 162: const { messages, querySource } = context 163: if (querySource !== 'repl_main_thread') { 164: return 165: } 166: const hasToolCalls = hasToolCallsInLastAssistantTurn(messages) 167: if (hasToolCalls) { 168: return 169: } 170: const docCount = trackedMagicDocs.size 171: if (docCount === 0) { 172: return 173: } 174: for (const docInfo of Array.from(trackedMagicDocs.values())) { 175: await updateMagicDoc(docInfo, context) 176: } 177: }) 178: export async function initMagicDocs(): Promise<void> { 179: if (process.env.USER_TYPE === 'ant') { 180: registerFileReadListener((filePath: string, content: string) => { 181: const result = detectMagicDocHeader(content) 182: if (result) { 183: registerMagicDoc(filePath) 184: } 185: }) 186: registerPostSamplingHook(updateMagicDocs) 187: } 188: }

File: src/services/MagicDocs/prompts.ts

typescript 1: import { join } from 'path' 2: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 3: import { getFsImplementation } from '../../utils/fsOperations.js' 4: function getUpdatePromptTemplate(): string { 5: return `IMPORTANT: This message and these instructions are NOT part of the actual user conversation. Do NOT include any references to "documentation updates", "magic docs", or these update instructions in the document content. 6: Based on the user conversation above (EXCLUDING this documentation update instruction message), update the Magic Doc file to incorporate any NEW learnings, insights, or information that would be valuable to preserve. 7: The file {{docPath}} has already been read for you. Here are its current contents: 8: <current_doc_content> 9: {{docContents}} 10: </current_doc_content> 11: Document title: {{docTitle}} 12: {{customInstructions}} 13: Your ONLY task is to use the Edit tool to update the documentation file if there is substantial new information to add, then stop. You can make multiple edits (update multiple sections as needed) - make all Edit tool calls in parallel in a single message. If there's nothing substantial to add, simply respond with a brief explanation and do not call any tools. 14: CRITICAL RULES FOR EDITING: 15: - Preserve the Magic Doc header exactly as-is: # MAGIC DOC: {{docTitle}} 16: - If there's an italicized line immediately after the header, preserve it exactly as-is 17: - Keep the document CURRENT with the latest state of the codebase - this is NOT a changelog or history 18: - Update information IN-PLACE to reflect the current state - do NOT append historical notes or track changes over time 19: - Remove or replace outdated information rather than adding "Previously..." or "Updated to..." notes 20: - Clean up or DELETE sections that are no longer relevant or don't align with the document's purpose 21: - Fix obvious errors: typos, grammar mistakes, broken formatting, incorrect information, or confusing statements 22: - Keep the document well organized: use clear headings, logical section order, consistent formatting, and proper nesting 23: DOCUMENTATION PHILOSOPHY - READ CAREFULLY: 24: - BE TERSE. High signal only. No filler words or unnecessary elaboration. 25: - Documentation is for OVERVIEWS, ARCHITECTURE, and ENTRY POINTS - not detailed code walkthroughs 26: - Do NOT duplicate information that's already obvious from reading the source code 27: - Do NOT document every function, parameter, or line number reference 28: - Focus on: WHY things exist, HOW components connect, WHERE to start reading, WHAT patterns are used 29: - Skip: detailed implementation steps, exhaustive API docs, play-by-play narratives 30: What TO document: 31: - High-level architecture and system design 32: - Non-obvious patterns, conventions, or gotchas 33: - Key entry points and where to start reading code 34: - Important design decisions and their rationale 35: - Critical dependencies or integration points 36: - References to related files, docs, or code (like a wiki) - help readers navigate to relevant context 37: What NOT to document: 38: - Anything obvious from reading the code itself 39: - Exhaustive lists of files, functions, or parameters 40: - Step-by-step implementation details 41: - Low-level code mechanics 42: - Information already in CLAUDE.md or other project docs 43: Use the Edit tool with file_path: {{docPath}} 44: REMEMBER: Only update if there is substantial new information. The Magic Doc header (# MAGIC DOC: {{docTitle}}) must remain unchanged.` 45: } 46: async function loadMagicDocsPrompt(): Promise<string> { 47: const fs = getFsImplementation() 48: const promptPath = join(getClaudeConfigHomeDir(), 'magic-docs', 'prompt.md') 49: try { 50: return await fs.readFile(promptPath, { encoding: 'utf-8' }) 51: } catch { 52: return getUpdatePromptTemplate() 53: } 54: } 55: function substituteVariables( 56: template: string, 57: variables: Record<string, string>, 58: ): string { 59: return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => 60: Object.prototype.hasOwnProperty.call(variables, key) 61: ? variables[key]! 62: : match, 63: ) 64: } 65: export async function buildMagicDocsUpdatePrompt( 66: docContents: string, 67: docPath: string, 68: docTitle: string, 69: instructions?: string, 70: ): Promise<string> { 71: const promptTemplate = await loadMagicDocsPrompt() 72: const customInstructions = instructions 73: ? ` 74: DOCUMENT-SPECIFIC UPDATE INSTRUCTIONS: 75: The document author has provided specific instructions for how this file should be updated. Pay extra attention to these instructions and follow them carefully: 76: "${instructions}" 77: These instructions take priority over the general rules below. Make sure your updates align with these specific guidelines.` 78: : '' 79: const variables = { 80: docContents, 81: docPath, 82: docTitle, 83: customInstructions, 84: } 85: return substituteVariables(promptTemplate, variables) 86: }

File: src/services/mcp/auth.ts

typescript 1: import { 2: discoverAuthorizationServerMetadata, 3: discoverOAuthServerInfo, 4: type OAuthClientProvider, 5: type OAuthDiscoveryState, 6: auth as sdkAuth, 7: refreshAuthorization as sdkRefreshAuthorization, 8: } from '@modelcontextprotocol/sdk/client/auth.js' 9: import { 10: InvalidGrantError, 11: OAuthError, 12: ServerError, 13: TemporarilyUnavailableError, 14: TooManyRequestsError, 15: } from '@modelcontextprotocol/sdk/server/auth/errors.js' 16: import { 17: type AuthorizationServerMetadata, 18: type OAuthClientInformation, 19: type OAuthClientInformationFull, 20: type OAuthClientMetadata, 21: OAuthErrorResponseSchema, 22: OAuthMetadataSchema, 23: type OAuthTokens, 24: OAuthTokensSchema, 25: } from '@modelcontextprotocol/sdk/shared/auth.js' 26: import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' 27: import axios from 'axios' 28: import { createHash, randomBytes, randomUUID } from 'crypto' 29: import { mkdir } from 'fs/promises' 30: import { createServer, type Server } from 'http' 31: import { join } from 'path' 32: import { parse } from 'url' 33: import xss from 'xss' 34: import { MCP_CLIENT_METADATA_URL } from '../../constants/oauth.js' 35: import { openBrowser } from '../../utils/browser.js' 36: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 37: import { errorMessage, getErrnoCode } from '../../utils/errors.js' 38: import * as lockfile from '../../utils/lockfile.js' 39: import { logMCPDebug } from '../../utils/log.js' 40: import { getPlatform } from '../../utils/platform.js' 41: import { getSecureStorage } from '../../utils/secureStorage/index.js' 42: import { clearKeychainCache } from '../../utils/secureStorage/macOsKeychainHelpers.js' 43: import type { SecureStorageData } from '../../utils/secureStorage/types.js' 44: import { sleep } from '../../utils/sleep.js' 45: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 46: import { logEvent } from '../analytics/index.js' 47: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../analytics/metadata.js' 48: import { buildRedirectUri, findAvailablePort } from './oauthPort.js' 49: import type { McpHTTPServerConfig, McpSSEServerConfig } from './types.js' 50: import { getLoggingSafeMcpBaseUrl } from './utils.js' 51: import { performCrossAppAccess, XaaTokenExchangeError } from './xaa.js' 52: import { 53: acquireIdpIdToken, 54: clearIdpIdToken, 55: discoverOidc, 56: getCachedIdpIdToken, 57: getIdpClientSecret, 58: getXaaIdpSettings, 59: isXaaEnabled, 60: } from './xaaIdpLogin.js' 61: const AUTH_REQUEST_TIMEOUT_MS = 30000 62: type MCPRefreshFailureReason = 63: | 'metadata_discovery_failed' 64: | 'no_client_info' 65: | 'no_tokens_returned' 66: | 'invalid_grant' 67: | 'transient_retries_exhausted' 68: | 'request_failed' 69: type MCPOAuthFlowErrorReason = 70: | 'cancelled' 71: | 'timeout' 72: | 'provider_denied' 73: | 'state_mismatch' 74: | 'port_unavailable' 75: | 'sdk_auth_failed' 76: | 'token_exchange_failed' 77: | 'unknown' 78: const MAX_LOCK_RETRIES = 5 79: const SENSITIVE_OAUTH_PARAMS = [ 80: 'state', 81: 'nonce', 82: 'code_challenge', 83: 'code_verifier', 84: 'code', 85: ] 86: function redactSensitiveUrlParams(url: string): string { 87: try { 88: const parsedUrl = new URL(url) 89: for (const param of SENSITIVE_OAUTH_PARAMS) { 90: if (parsedUrl.searchParams.has(param)) { 91: parsedUrl.searchParams.set(param, '[REDACTED]') 92: } 93: } 94: return parsedUrl.toString() 95: } catch { 96: return url 97: } 98: } 99: const NONSTANDARD_INVALID_GRANT_ALIASES = new Set([ 100: 'invalid_refresh_token', 101: 'expired_refresh_token', 102: 'token_expired', 103: ]) 104: export async function normalizeOAuthErrorBody( 105: response: Response, 106: ): Promise<Response> { 107: if (!response.ok) { 108: return response 109: } 110: const text = await response.text() 111: let parsed: unknown 112: try { 113: parsed = jsonParse(text) 114: } catch { 115: return new Response(text, response) 116: } 117: if (OAuthTokensSchema.safeParse(parsed).success) { 118: return new Response(text, response) 119: } 120: const result = OAuthErrorResponseSchema.safeParse(parsed) 121: if (!result.success) { 122: return new Response(text, response) 123: } 124: const normalized = NONSTANDARD_INVALID_GRANT_ALIASES.has(result.data.error) 125: ? { 126: error: 'invalid_grant', 127: error_description: 128: result.data.error_description ?? 129: `Server returned non-standard error code: ${result.data.error}`, 130: } 131: : result.data 132: return new Response(jsonStringify(normalized), { 133: status: 400, 134: statusText: 'Bad Request', 135: headers: response.headers, 136: }) 137: } 138: function createAuthFetch(): FetchLike { 139: return async (url: string | URL, init?: RequestInit) => { 140: const timeoutSignal = AbortSignal.timeout(AUTH_REQUEST_TIMEOUT_MS) 141: const isPost = init?.method?.toUpperCase() === 'POST' 142: if (!init?.signal) { 143: const response = await fetch(url, { ...init, signal: timeoutSignal }) 144: return isPost ? normalizeOAuthErrorBody(response) : response 145: } 146: const controller = new AbortController() 147: const abort = () => controller.abort() 148: init.signal.addEventListener('abort', abort) 149: timeoutSignal.addEventListener('abort', abort) 150: const cleanup = () => { 151: init.signal?.removeEventListener('abort', abort) 152: timeoutSignal.removeEventListener('abort', abort) 153: } 154: if (init.signal.aborted) { 155: controller.abort() 156: } 157: try { 158: const response = await fetch(url, { ...init, signal: controller.signal }) 159: cleanup() 160: return isPost ? normalizeOAuthErrorBody(response) : response 161: } catch (error) { 162: cleanup() 163: throw error 164: } 165: } 166: } 167: async function fetchAuthServerMetadata( 168: serverName: string, 169: serverUrl: string, 170: configuredMetadataUrl: string | undefined, 171: fetchFn?: FetchLike, 172: resourceMetadataUrl?: URL, 173: ): Promise<Awaited<ReturnType<typeof discoverAuthorizationServerMetadata>>> { 174: if (configuredMetadataUrl) { 175: if (!configuredMetadataUrl.startsWith('https://')) { 176: throw new Error( 177: `authServerMetadataUrl must use https:// (got: ${configuredMetadataUrl})`, 178: ) 179: } 180: const authFetch = fetchFn ?? createAuthFetch() 181: const response = await authFetch(configuredMetadataUrl, { 182: headers: { Accept: 'application/json' }, 183: }) 184: if (response.ok) { 185: return OAuthMetadataSchema.parse(await response.json()) 186: } 187: throw new Error( 188: `HTTP ${response.status} fetching configured auth server metadata from ${configuredMetadataUrl}`, 189: ) 190: } 191: try { 192: const { authorizationServerMetadata } = await discoverOAuthServerInfo( 193: serverUrl, 194: { 195: ...(fetchFn && { fetchFn }), 196: ...(resourceMetadataUrl && { resourceMetadataUrl }), 197: }, 198: ) 199: if (authorizationServerMetadata) { 200: return authorizationServerMetadata 201: } 202: } catch (err) { 203: logMCPDebug( 204: serverName, 205: `RFC 9728 discovery failed, falling back: ${errorMessage(err)}`, 206: ) 207: } 208: const url = new URL(serverUrl) 209: if (url.pathname === '/') { 210: return undefined 211: } 212: return discoverAuthorizationServerMetadata(url, { 213: ...(fetchFn && { fetchFn }), 214: }) 215: } 216: export class AuthenticationCancelledError extends Error { 217: constructor() { 218: super('Authentication was cancelled') 219: this.name = 'AuthenticationCancelledError' 220: } 221: } 222: export function getServerKey( 223: serverName: string, 224: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 225: ): string { 226: const configJson = jsonStringify({ 227: type: serverConfig.type, 228: url: serverConfig.url, 229: headers: serverConfig.headers || {}, 230: }) 231: const hash = createHash('sha256') 232: .update(configJson) 233: .digest('hex') 234: .substring(0, 16) 235: return `${serverName}|${hash}` 236: } 237: export function hasMcpDiscoveryButNoToken( 238: serverName: string, 239: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 240: ): boolean { 241: if (isXaaEnabled() && serverConfig.oauth?.xaa) { 242: return false 243: } 244: const serverKey = getServerKey(serverName, serverConfig) 245: const entry = getSecureStorage().read()?.mcpOAuth?.[serverKey] 246: return entry !== undefined && !entry.accessToken && !entry.refreshToken 247: } 248: async function revokeToken({ 249: serverName, 250: endpoint, 251: token, 252: tokenTypeHint, 253: clientId, 254: clientSecret, 255: accessToken, 256: authMethod = 'client_secret_basic', 257: }: { 258: serverName: string 259: endpoint: string 260: token: string 261: tokenTypeHint: 'access_token' | 'refresh_token' 262: clientId?: string 263: clientSecret?: string 264: accessToken?: string 265: authMethod?: 'client_secret_basic' | 'client_secret_post' 266: }): Promise<void> { 267: const params = new URLSearchParams() 268: params.set('token', token) 269: params.set('token_type_hint', tokenTypeHint) 270: const headers: Record<string, string> = { 271: 'Content-Type': 'application/x-www-form-urlencoded', 272: } 273: if (clientId && clientSecret) { 274: if (authMethod === 'client_secret_post') { 275: params.set('client_id', clientId) 276: params.set('client_secret', clientSecret) 277: } else { 278: const basic = Buffer.from( 279: `${encodeURIComponent(clientId)}:${encodeURIComponent(clientSecret)}`, 280: ).toString('base64') 281: headers.Authorization = `Basic ${basic}` 282: } 283: } else if (clientId) { 284: params.set('client_id', clientId) 285: } else { 286: logMCPDebug( 287: serverName, 288: `No client_id available for ${tokenTypeHint} revocation - server may reject`, 289: ) 290: } 291: try { 292: await axios.post(endpoint, params, { headers }) 293: logMCPDebug(serverName, `Successfully revoked ${tokenTypeHint}`) 294: } catch (error: unknown) { 295: if ( 296: axios.isAxiosError(error) && 297: error.response?.status === 401 && 298: accessToken 299: ) { 300: logMCPDebug( 301: serverName, 302: `Got 401, retrying ${tokenTypeHint} revocation with Bearer auth`, 303: ) 304: params.delete('client_id') 305: params.delete('client_secret') 306: await axios.post(endpoint, params, { 307: headers: { ...headers, Authorization: `Bearer ${accessToken}` }, 308: }) 309: logMCPDebug( 310: serverName, 311: `Successfully revoked ${tokenTypeHint} with Bearer auth`, 312: ) 313: } else { 314: throw error 315: } 316: } 317: } 318: export async function revokeServerTokens( 319: serverName: string, 320: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 321: { preserveStepUpState = false }: { preserveStepUpState?: boolean } = {}, 322: ): Promise<void> { 323: const storage = getSecureStorage() 324: const existingData = storage.read() 325: if (!existingData?.mcpOAuth) return 326: const serverKey = getServerKey(serverName, serverConfig) 327: const tokenData = existingData.mcpOAuth[serverKey] 328: if (tokenData?.accessToken || tokenData?.refreshToken) { 329: try { 330: const asUrl = 331: tokenData.discoveryState?.authorizationServerUrl ?? serverConfig.url 332: const metadata = await fetchAuthServerMetadata( 333: serverName, 334: asUrl, 335: serverConfig.oauth?.authServerMetadataUrl, 336: ) 337: if (!metadata) { 338: logMCPDebug(serverName, 'No OAuth metadata found') 339: } else { 340: const revocationEndpoint = 341: 'revocation_endpoint' in metadata 342: ? metadata.revocation_endpoint 343: : null 344: if (!revocationEndpoint) { 345: logMCPDebug(serverName, 'Server does not support token revocation') 346: } else { 347: const revocationEndpointStr = String(revocationEndpoint) 348: const authMethods = 349: ('revocation_endpoint_auth_methods_supported' in metadata 350: ? metadata.revocation_endpoint_auth_methods_supported 351: : undefined) ?? 352: ('token_endpoint_auth_methods_supported' in metadata 353: ? metadata.token_endpoint_auth_methods_supported 354: : undefined) 355: const authMethod: 'client_secret_basic' | 'client_secret_post' = 356: authMethods && 357: !authMethods.includes('client_secret_basic') && 358: authMethods.includes('client_secret_post') 359: ? 'client_secret_post' 360: : 'client_secret_basic' 361: logMCPDebug( 362: serverName, 363: `Revoking tokens via ${revocationEndpointStr} (${authMethod})`, 364: ) 365: if (tokenData.refreshToken) { 366: try { 367: await revokeToken({ 368: serverName, 369: endpoint: revocationEndpointStr, 370: token: tokenData.refreshToken, 371: tokenTypeHint: 'refresh_token', 372: clientId: tokenData.clientId, 373: clientSecret: tokenData.clientSecret, 374: accessToken: tokenData.accessToken, 375: authMethod, 376: }) 377: } catch (error: unknown) { 378: logMCPDebug( 379: serverName, 380: `Failed to revoke refresh token: ${errorMessage(error)}`, 381: ) 382: } 383: } 384: if (tokenData.accessToken) { 385: try { 386: await revokeToken({ 387: serverName, 388: endpoint: revocationEndpointStr, 389: token: tokenData.accessToken, 390: tokenTypeHint: 'access_token', 391: clientId: tokenData.clientId, 392: clientSecret: tokenData.clientSecret, 393: accessToken: tokenData.accessToken, 394: authMethod, 395: }) 396: } catch (error: unknown) { 397: logMCPDebug( 398: serverName, 399: `Failed to revoke access token: ${errorMessage(error)}`, 400: ) 401: } 402: } 403: } 404: } 405: } catch (error: unknown) { 406: logMCPDebug(serverName, `Failed to revoke tokens: ${errorMessage(error)}`) 407: } 408: } else { 409: logMCPDebug(serverName, 'No tokens to revoke') 410: } 411: clearServerTokensFromLocalStorage(serverName, serverConfig) 412: if ( 413: preserveStepUpState && 414: tokenData && 415: (tokenData.stepUpScope || tokenData.discoveryState) 416: ) { 417: const freshData = storage.read() || {} 418: const updatedData: SecureStorageData = { 419: ...freshData, 420: mcpOAuth: { 421: ...freshData.mcpOAuth, 422: [serverKey]: { 423: ...freshData.mcpOAuth?.[serverKey], 424: serverName, 425: serverUrl: serverConfig.url, 426: accessToken: freshData.mcpOAuth?.[serverKey]?.accessToken ?? '', 427: expiresAt: freshData.mcpOAuth?.[serverKey]?.expiresAt ?? 0, 428: ...(tokenData.stepUpScope 429: ? { stepUpScope: tokenData.stepUpScope } 430: : {}), 431: ...(tokenData.discoveryState 432: ? { 433: // Strip legacy bulky metadata fields here too so users with 434: // existing overflowed blobs recover on next re-auth (#30337). 435: discoveryState: { 436: authorizationServerUrl: 437: tokenData.discoveryState.authorizationServerUrl, 438: resourceMetadataUrl: 439: tokenData.discoveryState.resourceMetadataUrl, 440: }, 441: } 442: : {}), 443: }, 444: }, 445: } 446: storage.update(updatedData) 447: logMCPDebug(serverName, 'Preserved step-up auth state across revocation') 448: } 449: } 450: export function clearServerTokensFromLocalStorage( 451: serverName: string, 452: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 453: ): void { 454: const storage = getSecureStorage() 455: const existingData = storage.read() 456: if (!existingData?.mcpOAuth) return 457: const serverKey = getServerKey(serverName, serverConfig) 458: if (existingData.mcpOAuth[serverKey]) { 459: delete existingData.mcpOAuth[serverKey] 460: storage.update(existingData) 461: logMCPDebug(serverName, 'Cleared stored tokens') 462: } 463: } 464: type WWWAuthenticateParams = { 465: scope?: string 466: resourceMetadataUrl?: URL 467: } 468: type XaaFailureStage = 469: | 'idp_login' 470: | 'discovery' 471: | 'token_exchange' 472: | 'jwt_bearer' 473: async function performMCPXaaAuth( 474: serverName: string, 475: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 476: onAuthorizationUrl: (url: string) => void, 477: abortSignal?: AbortSignal, 478: skipBrowserOpen?: boolean, 479: ): Promise<void> { 480: if (!serverConfig.oauth?.xaa) { 481: throw new Error('XAA: oauth.xaa must be set') 482: } 483: const idp = getXaaIdpSettings() 484: if (!idp) { 485: throw new Error( 486: "XAA: no IdP connection configured. Run 'claude mcp xaa setup --issuer <url> --client-id <id> --client-secret' to configure.", 487: ) 488: } 489: const clientId = serverConfig.oauth?.clientId 490: if (!clientId) { 491: throw new Error( 492: `XAA: server '${serverName}' needs an AS client_id. Re-add with --client-id.`, 493: ) 494: } 495: const clientConfig = getMcpClientConfig(serverName, serverConfig) 496: const clientSecret = clientConfig?.clientSecret 497: if (!clientSecret) { 498: const wantedKey = getServerKey(serverName, serverConfig) 499: const haveKeys = Object.keys( 500: getSecureStorage().read()?.mcpOAuthClientConfig ?? {}, 501: ) 502: const headersForLogging = Object.fromEntries( 503: Object.entries(serverConfig.headers ?? {}).map(([k, v]) => 504: k.toLowerCase() === 'authorization' ? [k, '[REDACTED]'] : [k, v], 505: ), 506: ) 507: logMCPDebug( 508: serverName, 509: `XAA: secret lookup miss. wanted=${wantedKey} have=[${haveKeys.join(', ')}] configHeaders=${jsonStringify(headersForLogging)}`, 510: ) 511: throw new Error( 512: `XAA: AS client secret not found for '${serverName}'. Re-add with --client-secret.`, 513: ) 514: } 515: logMCPDebug(serverName, 'XAA: starting cross-app access flow') 516: const idpClientSecret = getIdpClientSecret(idp.issuer) 517: const idTokenCacheHit = getCachedIdpIdToken(idp.issuer) !== undefined 518: let failureStage: XaaFailureStage = 'idp_login' 519: try { 520: let idToken 521: try { 522: idToken = await acquireIdpIdToken({ 523: idpIssuer: idp.issuer, 524: idpClientId: idp.clientId, 525: idpClientSecret, 526: callbackPort: idp.callbackPort, 527: onAuthorizationUrl, 528: skipBrowserOpen, 529: abortSignal, 530: }) 531: } catch (e) { 532: if (abortSignal?.aborted) throw new AuthenticationCancelledError() 533: throw e 534: } 535: failureStage = 'discovery' 536: const oidc = await discoverOidc(idp.issuer) 537: failureStage = 'token_exchange' 538: let tokens 539: try { 540: tokens = await performCrossAppAccess( 541: serverConfig.url, 542: { 543: clientId, 544: clientSecret, 545: idpClientId: idp.clientId, 546: idpClientSecret, 547: idpIdToken: idToken, 548: idpTokenEndpoint: oidc.token_endpoint, 549: }, 550: serverName, 551: abortSignal, 552: ) 553: } catch (e) { 554: if (abortSignal?.aborted) throw new AuthenticationCancelledError() 555: const msg = errorMessage(e) 556: if (e instanceof XaaTokenExchangeError) { 557: if (e.shouldClearIdToken) { 558: clearIdpIdToken(idp.issuer) 559: logMCPDebug( 560: serverName, 561: 'XAA: cleared cached id_token after token-exchange failure', 562: ) 563: } 564: } else if ( 565: msg.includes('PRM discovery failed') || 566: msg.includes('AS metadata discovery failed') || 567: msg.includes('no authorization server supports jwt-bearer') 568: ) { 569: failureStage = 'discovery' 570: } else if (msg.includes('jwt-bearer')) { 571: failureStage = 'jwt_bearer' 572: } 573: throw e 574: } 575: const storage = getSecureStorage() 576: const existingData = storage.read() || {} 577: const serverKey = getServerKey(serverName, serverConfig) 578: const prev = existingData.mcpOAuth?.[serverKey] 579: storage.update({ 580: ...existingData, 581: mcpOAuth: { 582: ...existingData.mcpOAuth, 583: [serverKey]: { 584: ...prev, 585: serverName, 586: serverUrl: serverConfig.url, 587: accessToken: tokens.access_token, 588: refreshToken: tokens.refresh_token ?? prev?.refreshToken, 589: expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, 590: scope: tokens.scope, 591: clientId, 592: clientSecret, 593: discoveryState: { 594: authorizationServerUrl: tokens.authorizationServerUrl, 595: }, 596: }, 597: }, 598: }) 599: logMCPDebug(serverName, 'XAA: tokens saved') 600: logEvent('tengu_mcp_oauth_flow_success', { 601: authMethod: 602: 'xaa' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 603: idTokenCacheHit, 604: }) 605: } catch (e) { 606: if (e instanceof AuthenticationCancelledError) { 607: throw e 608: } 609: logEvent('tengu_mcp_oauth_flow_failure', { 610: authMethod: 611: 'xaa' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 612: xaaFailureStage: 613: failureStage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 614: idTokenCacheHit, 615: }) 616: throw e 617: } 618: } 619: export async function performMCPOAuthFlow( 620: serverName: string, 621: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 622: onAuthorizationUrl: (url: string) => void, 623: abortSignal?: AbortSignal, 624: options?: { 625: skipBrowserOpen?: boolean 626: onWaitingForCallback?: (submit: (callbackUrl: string) => void) => void 627: }, 628: ): Promise<void> { 629: if (serverConfig.oauth?.xaa) { 630: if (!isXaaEnabled()) { 631: throw new Error( 632: `XAA is not enabled (set CLAUDE_CODE_ENABLE_XAA=1). Remove 'oauth.xaa' from server '${serverName}' to use the standard consent flow.`, 633: ) 634: } 635: logEvent('tengu_mcp_oauth_flow_start', { 636: isOAuthFlow: true, 637: authMethod: 638: 'xaa' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 639: transportType: 640: serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 641: ...(getLoggingSafeMcpBaseUrl(serverConfig) 642: ? { 643: mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 644: serverConfig, 645: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 646: } 647: : {}), 648: }) 649: await performMCPXaaAuth( 650: serverName, 651: serverConfig, 652: onAuthorizationUrl, 653: abortSignal, 654: options?.skipBrowserOpen, 655: ) 656: return 657: } 658: const storage = getSecureStorage() 659: const serverKey = getServerKey(serverName, serverConfig) 660: const cachedEntry = storage.read()?.mcpOAuth?.[serverKey] 661: const cachedStepUpScope = cachedEntry?.stepUpScope 662: const cachedResourceMetadataUrl = 663: cachedEntry?.discoveryState?.resourceMetadataUrl 664: clearServerTokensFromLocalStorage(serverName, serverConfig) 665: let resourceMetadataUrl: URL | undefined 666: if (cachedResourceMetadataUrl) { 667: try { 668: resourceMetadataUrl = new URL(cachedResourceMetadataUrl) 669: } catch { 670: logMCPDebug( 671: serverName, 672: `Invalid cached resourceMetadataUrl: ${cachedResourceMetadataUrl}`, 673: ) 674: } 675: } 676: const wwwAuthParams: WWWAuthenticateParams = { 677: scope: cachedStepUpScope, 678: resourceMetadataUrl, 679: } 680: const flowAttemptId = randomUUID() 681: logEvent('tengu_mcp_oauth_flow_start', { 682: flowAttemptId: 683: flowAttemptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 684: isOAuthFlow: true, 685: transportType: 686: serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 687: ...(getLoggingSafeMcpBaseUrl(serverConfig) 688: ? { 689: mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 690: serverConfig, 691: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 692: } 693: : {}), 694: }) 695: let authorizationCodeObtained = false 696: try { 697: const configuredCallbackPort = serverConfig.oauth?.callbackPort 698: const port = configuredCallbackPort ?? (await findAvailablePort()) 699: const redirectUri = buildRedirectUri(port) 700: logMCPDebug( 701: serverName, 702: `Using redirect port: ${port}${configuredCallbackPort ? ' (from config)' : ''}`, 703: ) 704: const provider = new ClaudeAuthProvider( 705: serverName, 706: serverConfig, 707: redirectUri, 708: true, 709: onAuthorizationUrl, 710: options?.skipBrowserOpen, 711: ) 712: try { 713: const metadata = await fetchAuthServerMetadata( 714: serverName, 715: serverConfig.url, 716: serverConfig.oauth?.authServerMetadataUrl, 717: undefined, 718: wwwAuthParams.resourceMetadataUrl, 719: ) 720: if (metadata) { 721: provider.setMetadata(metadata) 722: logMCPDebug( 723: serverName, 724: `Fetched OAuth metadata with scope: ${getScopeFromMetadata(metadata) || 'NONE'}`, 725: ) 726: } 727: } catch (error) { 728: logMCPDebug( 729: serverName, 730: `Failed to fetch OAuth metadata: ${errorMessage(error)}`, 731: ) 732: } 733: const oauthState = await provider.state() 734: let server: Server | null = null 735: let timeoutId: NodeJS.Timeout | null = null 736: let abortHandler: (() => void) | null = null 737: const cleanup = () => { 738: if (server) { 739: server.removeAllListeners() 740: server.on('error', () => {}) 741: server.close() 742: server = null 743: } 744: if (timeoutId) { 745: clearTimeout(timeoutId) 746: timeoutId = null 747: } 748: if (abortSignal && abortHandler) { 749: abortSignal.removeEventListener('abort', abortHandler) 750: abortHandler = null 751: } 752: logMCPDebug(serverName, `MCP OAuth server cleaned up`) 753: } 754: const authorizationCode = await new Promise<string>((resolve, reject) => { 755: let resolved = false 756: const resolveOnce = (code: string) => { 757: if (resolved) return 758: resolved = true 759: resolve(code) 760: } 761: const rejectOnce = (error: Error) => { 762: if (resolved) return 763: resolved = true 764: reject(error) 765: } 766: if (abortSignal) { 767: abortHandler = () => { 768: cleanup() 769: rejectOnce(new AuthenticationCancelledError()) 770: } 771: if (abortSignal.aborted) { 772: abortHandler() 773: return 774: } 775: abortSignal.addEventListener('abort', abortHandler) 776: } 777: if (options?.onWaitingForCallback) { 778: options.onWaitingForCallback((callbackUrl: string) => { 779: try { 780: const parsed = new URL(callbackUrl) 781: const code = parsed.searchParams.get('code') 782: const state = parsed.searchParams.get('state') 783: const error = parsed.searchParams.get('error') 784: if (error) { 785: const errorDescription = 786: parsed.searchParams.get('error_description') || '' 787: cleanup() 788: rejectOnce( 789: new Error(`OAuth error: ${error} - ${errorDescription}`), 790: ) 791: return 792: } 793: if (!code) { 794: // Not a valid callback URL, ignore so the user can try again 795: return 796: } 797: if (state !== oauthState) { 798: cleanup() 799: rejectOnce( 800: new Error('OAuth state mismatch - possible CSRF attack'), 801: ) 802: return 803: } 804: logMCPDebug( 805: serverName, 806: `Received auth code via manual callback URL`, 807: ) 808: cleanup() 809: resolveOnce(code) 810: } catch { 811: } 812: }) 813: } 814: server = createServer((req, res) => { 815: const parsedUrl = parse(req.url || '', true) 816: if (parsedUrl.pathname === '/callback') { 817: const code = parsedUrl.query.code as string 818: const state = parsedUrl.query.state as string 819: const error = parsedUrl.query.error 820: const errorDescription = parsedUrl.query.error_description as string 821: const errorUri = parsedUrl.query.error_uri as string 822: if (!error && state !== oauthState) { 823: res.writeHead(400, { 'Content-Type': 'text/html' }) 824: res.end( 825: `<h1>Authentication Error</h1><p>Invalid state parameter. Please try again.</p><p>You can close this window.</p>`, 826: ) 827: cleanup() 828: rejectOnce(new Error('OAuth state mismatch - possible CSRF attack')) 829: return 830: } 831: if (error) { 832: res.writeHead(200, { 'Content-Type': 'text/html' }) 833: const sanitizedError = xss(String(error)) 834: const sanitizedErrorDescription = errorDescription 835: ? xss(String(errorDescription)) 836: : '' 837: res.end( 838: `<h1>Authentication Error</h1><p>${sanitizedError}: ${sanitizedErrorDescription}</p><p>You can close this window.</p>`, 839: ) 840: cleanup() 841: let errorMessage = `OAuth error: ${error}` 842: if (errorDescription) { 843: errorMessage += ` - ${errorDescription}` 844: } 845: if (errorUri) { 846: errorMessage += ` (See: ${errorUri})` 847: } 848: rejectOnce(new Error(errorMessage)) 849: return 850: } 851: if (code) { 852: res.writeHead(200, { 'Content-Type': 'text/html' }) 853: res.end( 854: `<h1>Authentication Successful</h1><p>You can close this window. Return to Claude Code.</p>`, 855: ) 856: cleanup() 857: resolveOnce(code) 858: } 859: } 860: }) 861: server.on('error', (err: NodeJS.ErrnoException) => { 862: cleanup() 863: if (err.code === 'EADDRINUSE') { 864: const findCmd = 865: getPlatform() === 'windows' 866: ? `netstat -ano | findstr :${port}` 867: : `lsof -ti:${port} -sTCP:LISTEN` 868: rejectOnce( 869: new Error( 870: `OAuth callback port ${port} is already in use — another process may be holding it. ` + 871: `Run \`${findCmd}\` to find it.`, 872: ), 873: ) 874: } else { 875: rejectOnce(new Error(`OAuth callback server failed: ${err.message}`)) 876: } 877: }) 878: server.listen(port, '127.0.0.1', async () => { 879: try { 880: logMCPDebug(serverName, `Starting SDK auth`) 881: logMCPDebug(serverName, `Server URL: ${serverConfig.url}`) 882: const result = await sdkAuth(provider, { 883: serverUrl: serverConfig.url, 884: scope: wwwAuthParams.scope, 885: resourceMetadataUrl: wwwAuthParams.resourceMetadataUrl, 886: }) 887: logMCPDebug(serverName, `Initial auth result: ${result}`) 888: if (result !== 'REDIRECT') { 889: logMCPDebug( 890: serverName, 891: `Unexpected auth result, expected REDIRECT: ${result}`, 892: ) 893: } 894: } catch (error) { 895: logMCPDebug(serverName, `SDK auth error: ${error}`) 896: cleanup() 897: rejectOnce(new Error(`SDK auth failed: ${errorMessage(error)}`)) 898: } 899: }) 900: server.unref() 901: timeoutId = setTimeout( 902: (cleanup, rejectOnce) => { 903: cleanup() 904: rejectOnce(new Error('Authentication timeout')) 905: }, 906: 5 * 60 * 1000, 907: cleanup, 908: rejectOnce, 909: ) 910: timeoutId.unref() 911: }) 912: authorizationCodeObtained = true 913: logMCPDebug(serverName, `Completing auth flow with authorization code`) 914: const result = await sdkAuth(provider, { 915: serverUrl: serverConfig.url, 916: authorizationCode, 917: resourceMetadataUrl: wwwAuthParams.resourceMetadataUrl, 918: }) 919: logMCPDebug(serverName, `Auth result: ${result}`) 920: if (result === 'AUTHORIZED') { 921: const savedTokens = await provider.tokens() 922: logMCPDebug( 923: serverName, 924: `Tokens after auth: ${savedTokens ? 'Present' : 'Missing'}`, 925: ) 926: if (savedTokens) { 927: logMCPDebug( 928: serverName, 929: `Token access_token length: ${savedTokens.access_token?.length}`, 930: ) 931: logMCPDebug(serverName, `Token expires_in: ${savedTokens.expires_in}`) 932: } 933: logEvent('tengu_mcp_oauth_flow_success', { 934: flowAttemptId: 935: flowAttemptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 936: transportType: 937: serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 938: ...(getLoggingSafeMcpBaseUrl(serverConfig) 939: ? { 940: mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 941: serverConfig, 942: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 943: } 944: : {}), 945: }) 946: } else { 947: throw new Error('Unexpected auth result: ' + result) 948: } 949: } catch (error) { 950: logMCPDebug(serverName, `Error during auth completion: ${error}`) 951: let reason: MCPOAuthFlowErrorReason = 'unknown' 952: let oauthErrorCode: string | undefined 953: let httpStatus: number | undefined 954: if (error instanceof AuthenticationCancelledError) { 955: reason = 'cancelled' 956: } else if (authorizationCodeObtained) { 957: reason = 'token_exchange_failed' 958: } else { 959: const msg = errorMessage(error) 960: if (msg.includes('Authentication timeout')) { 961: reason = 'timeout' 962: } else if (msg.includes('OAuth state mismatch')) { 963: reason = 'state_mismatch' 964: } else if (msg.includes('OAuth error:')) { 965: reason = 'provider_denied' 966: } else if ( 967: msg.includes('already in use') || 968: msg.includes('EADDRINUSE') || 969: msg.includes('callback server failed') || 970: msg.includes('No available port') 971: ) { 972: reason = 'port_unavailable' 973: } else if (msg.includes('SDK auth failed')) { 974: reason = 'sdk_auth_failed' 975: } 976: } 977: if (error instanceof OAuthError) { 978: oauthErrorCode = error.errorCode 979: const statusMatch = error.message.match(/^HTTP (\d{3}):/) 980: if (statusMatch) { 981: httpStatus = Number(statusMatch[1]) 982: } 983: if ( 984: error.errorCode === 'invalid_client' && 985: error.message.includes('Client not found') 986: ) { 987: const storage = getSecureStorage() 988: const existingData = storage.read() || {} 989: const serverKey = getServerKey(serverName, serverConfig) 990: if (existingData.mcpOAuth?.[serverKey]) { 991: delete existingData.mcpOAuth[serverKey].clientId 992: delete existingData.mcpOAuth[serverKey].clientSecret 993: storage.update(existingData) 994: } 995: } 996: } 997: logEvent('tengu_mcp_oauth_flow_error', { 998: flowAttemptId: 999: flowAttemptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1000: reason: 1001: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1002: error_code: 1003: oauthErrorCode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1004: http_status: 1005: httpStatus?.toString() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1006: transportType: 1007: serverConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1008: ...(getLoggingSafeMcpBaseUrl(serverConfig) 1009: ? { 1010: mcpServerBaseUrl: getLoggingSafeMcpBaseUrl( 1011: serverConfig, 1012: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1013: } 1014: : {}), 1015: }) 1016: throw error 1017: } 1018: } 1019: export function wrapFetchWithStepUpDetection( 1020: baseFetch: FetchLike, 1021: provider: ClaudeAuthProvider, 1022: ): FetchLike { 1023: return async (url, init) => { 1024: const response = await baseFetch(url, init) 1025: if (response.status === 403) { 1026: const wwwAuth = response.headers.get('WWW-Authenticate') 1027: if (wwwAuth?.includes('insufficient_scope')) { 1028: const match = wwwAuth.match(/scope=(?:"([^"]+)"|([^\s,]+))/) 1029: const scope = match?.[1] ?? match?.[2] 1030: if (scope) { 1031: provider.markStepUpPending(scope) 1032: } 1033: } 1034: } 1035: return response 1036: } 1037: } 1038: export class ClaudeAuthProvider implements OAuthClientProvider { 1039: private serverName: string 1040: private serverConfig: McpSSEServerConfig | McpHTTPServerConfig 1041: private redirectUri: string 1042: private handleRedirection: boolean 1043: private _codeVerifier?: string 1044: private _authorizationUrl?: string 1045: private _state?: string 1046: private _scopes?: string 1047: private _metadata?: Awaited< 1048: ReturnType<typeof discoverAuthorizationServerMetadata> 1049: > 1050: private _refreshInProgress?: Promise<OAuthTokens | undefined> 1051: private _pendingStepUpScope?: string 1052: private onAuthorizationUrlCallback?: (url: string) => void 1053: private skipBrowserOpen: boolean 1054: constructor( 1055: serverName: string, 1056: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 1057: redirectUri: string = buildRedirectUri(), 1058: handleRedirection = false, 1059: onAuthorizationUrl?: (url: string) => void, 1060: skipBrowserOpen?: boolean, 1061: ) { 1062: this.serverName = serverName 1063: this.serverConfig = serverConfig 1064: this.redirectUri = redirectUri 1065: this.handleRedirection = handleRedirection 1066: this.onAuthorizationUrlCallback = onAuthorizationUrl 1067: this.skipBrowserOpen = skipBrowserOpen ?? false 1068: } 1069: get redirectUrl(): string { 1070: return this.redirectUri 1071: } 1072: get authorizationUrl(): string | undefined { 1073: return this._authorizationUrl 1074: } 1075: get clientMetadata(): OAuthClientMetadata { 1076: const metadata: OAuthClientMetadata = { 1077: client_name: `Claude Code (${this.serverName})`, 1078: redirect_uris: [this.redirectUri], 1079: grant_types: ['authorization_code', 'refresh_token'], 1080: response_types: ['code'], 1081: token_endpoint_auth_method: 'none', // Public client 1082: } 1083: // Include scope from metadata if available 1084: const metadataScope = getScopeFromMetadata(this._metadata) 1085: if (metadataScope) { 1086: metadata.scope = metadataScope 1087: logMCPDebug( 1088: this.serverName, 1089: `Using scope from metadata: ${metadata.scope}`, 1090: ) 1091: } 1092: return metadata 1093: } 1094: /** 1095: * CIMD (SEP-991): URL-based client_id. When the auth server advertises 1096: * client_id_metadata_document_supported: true, the SDK uses this URL as the 1097: * client_id instead of performing Dynamic Client Registration. 1098: * Override via MCP_OAUTH_CLIENT_METADATA_URL env var (e.g. for testing, FedStart). 1099: */ 1100: get clientMetadataUrl(): string | undefined { 1101: const override = process.env.MCP_OAUTH_CLIENT_METADATA_URL 1102: if (override) { 1103: logMCPDebug(this.serverName, `Using CIMD URL from env: ${override}`) 1104: return override 1105: } 1106: return MCP_CLIENT_METADATA_URL 1107: } 1108: setMetadata( 1109: metadata: Awaited<ReturnType<typeof discoverAuthorizationServerMetadata>>, 1110: ): void { 1111: this._metadata = metadata 1112: } 1113: /** 1114: * Called by the fetch wrapper when a 403 insufficient_scope response is 1115: * detected. Setting this causes tokens() to omit refresh_token, forcing 1116: * the SDK's authInternal to skip its (useless) refresh path and fall through 1117: * to startAuthorization → redirectToAuthorization → step-up persistence. 1118: * RFC 6749 §6 forbids scope elevation via refresh, so refreshing would just 1119: * return the same-scoped token and the retry would 403 again. 1120: */ 1121: markStepUpPending(scope: string): void { 1122: this._pendingStepUpScope = scope 1123: logMCPDebug(this.serverName, `Marked step-up pending: ${scope}`) 1124: } 1125: async state(): Promise<string> { 1126: // Generate state if not already generated for this instance 1127: if (!this._state) { 1128: this._state = randomBytes(32).toString('base64url') 1129: logMCPDebug(this.serverName, 'Generated new OAuth state') 1130: } 1131: return this._state 1132: } 1133: async clientInformation(): Promise<OAuthClientInformation | undefined> { 1134: const storage = getSecureStorage() 1135: const data = storage.read() 1136: const serverKey = getServerKey(this.serverName, this.serverConfig) 1137: // Check session credentials first (from DCR or previous auth) 1138: const storedInfo = data?.mcpOAuth?.[serverKey] 1139: if (storedInfo?.clientId) { 1140: logMCPDebug(this.serverName, `Found client info`) 1141: return { 1142: client_id: storedInfo.clientId, 1143: client_secret: storedInfo.clientSecret, 1144: } 1145: } 1146: // Fallback: pre-configured client ID from server config 1147: const configClientId = this.serverConfig.oauth?.clientId 1148: if (configClientId) { 1149: const clientConfig = data?.mcpOAuthClientConfig?.[serverKey] 1150: logMCPDebug(this.serverName, `Using pre-configured client ID`) 1151: return { 1152: client_id: configClientId, 1153: client_secret: clientConfig?.clientSecret, 1154: } 1155: } 1156: // If we don't have stored client info, return undefined to trigger registration 1157: logMCPDebug(this.serverName, `No client info found`) 1158: return undefined 1159: } 1160: async saveClientInformation( 1161: clientInformation: OAuthClientInformationFull, 1162: ): Promise<void> { 1163: const storage = getSecureStorage() 1164: const existingData = storage.read() || {} 1165: const serverKey = getServerKey(this.serverName, this.serverConfig) 1166: const updatedData: SecureStorageData = { 1167: ...existingData, 1168: mcpOAuth: { 1169: ...existingData.mcpOAuth, 1170: [serverKey]: { 1171: ...existingData.mcpOAuth?.[serverKey], 1172: serverName: this.serverName, 1173: serverUrl: this.serverConfig.url, 1174: clientId: clientInformation.client_id, 1175: clientSecret: clientInformation.client_secret, 1176: // Provide default values for required fields if not present 1177: accessToken: existingData.mcpOAuth?.[serverKey]?.accessToken || '', 1178: expiresAt: existingData.mcpOAuth?.[serverKey]?.expiresAt || 0, 1179: }, 1180: }, 1181: } 1182: storage.update(updatedData) 1183: } 1184: async tokens(): Promise<OAuthTokens | undefined> { 1185: // Cross-process token changes (another CC instance refreshed or invalidated) 1186: // are picked up via the keychain cache TTL (see macOsKeychainStorage.ts). 1187: // In-process writes already invalidate the cache via storage.update(). 1188: // We do NOT clearKeychainCache() here — tokens() is called by the MCP SDK's 1189: // _commonHeaders on every request, and forcing a cache miss would trigger 1190: // a blocking spawnSync(`security find-generic-password`) 30-40x/sec. 1191: // See CPU profile: spawnSync was 7.2% of total CPU after PR #19436. 1192: const storage = getSecureStorage() 1193: const data = await storage.readAsync() 1194: const serverKey = getServerKey(this.serverName, this.serverConfig) 1195: const tokenData = data?.mcpOAuth?.[serverKey] 1196: // XAA: a cached id_token plays the same UX role as a refresh_token — run 1197: // the silent exchange to get a fresh access_token without a browser. The 1198: // id_token does expire (we re-acquire via `xaa login` when it does); the 1199: // point is that while it's valid, re-auth is zero-interaction. 1200: // 1201: // Only fire when we don't have a refresh_token. If the AS returned one, 1202: // the normal refresh path (below) is cheaper — 1 request vs the 4-request 1203: // XAA chain. If that refresh is revoked, refreshAuthorization() clears it 1204: // (invalidateCredentials('tokens')), and the next tokens() falls through 1205: // to here. 1206: // 1207: // Fires on: 1208: // - never authed (!tokenData) → first connect, auto-auth 1209: // - SDK partial write {accessToken:''} → stale from past session 1210: // - expired/expiring, no refresh_token → proactive XAA re-auth 1211: // 1212: // No special-casing of {accessToken:'', expiresAt:0}. Yes, SDK auth() 1213: // writes that mid-flow (saveClientInformation defaults). But with this 1214: // auto-auth branch, the *first* tokens() call — before auth() writes 1215: // anything — fires xaaRefresh. If id_token is cached, SDK short-circuits 1216: // there and never reaches the write. If id_token isn't cached, xaaRefresh 1217: // returns undefined in ~1 keychain read, auth() proceeds, writes the 1218: // marker, calls tokens() again, xaaRefresh fails again identically. 1219: // Harmless redundancy, not a wasted exchange. And guarding on `!==''` 1220: // permanently bricks auto-auth when a *prior* session left that marker 1221: // in keychain — real bug seen with xaa.dev. 1222: // 1223: // xaaRefresh() internally short-circuits to undefined when the id_token 1224: // isn't cached (or settings.xaaIdp is gone) → we fall through to the 1225: // existing needs-auth path → user runs `xaa login`. 1226: // 1227: if ( 1228: isXaaEnabled() && 1229: this.serverConfig.oauth?.xaa && 1230: !tokenData?.refreshToken && 1231: (!tokenData?.accessToken || 1232: (tokenData.expiresAt - Date.now()) / 1000 <= 300) 1233: ) { 1234: if (!this._refreshInProgress) { 1235: logMCPDebug( 1236: this.serverName, 1237: tokenData 1238: ? `XAA: access_token expiring, attempting silent exchange` 1239: : `XAA: no access_token yet, attempting silent exchange`, 1240: ) 1241: this._refreshInProgress = this.xaaRefresh().finally(() => { 1242: this._refreshInProgress = undefined 1243: }) 1244: } 1245: try { 1246: const refreshed = await this._refreshInProgress 1247: if (refreshed) return refreshed 1248: } catch (e) { 1249: logMCPDebug( 1250: this.serverName, 1251: `XAA silent exchange failed: ${errorMessage(e)}`, 1252: ) 1253: } 1254: // Fall through. Either id_token isn't cached (xaaRefresh returned 1255: // undefined) or the exchange errored. Normal path below handles both: 1256: // !tokenData → undefined → 401 → needs-auth; expired → undefined → same. 1257: } 1258: if (!tokenData) { 1259: logMCPDebug(this.serverName, `No token data found`) 1260: return undefined 1261: } 1262: // Check if token is expired 1263: const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 1264: // Step-up check: if a 403 insufficient_scope was detected and the current 1265: // token doesn't have the requested scope, omit refresh_token below so the 1266: // SDK skips refresh and falls through to the PKCE flow. 1267: const currentScopes = tokenData.scope?.split(' ') ?? [] 1268: const needsStepUp = 1269: this._pendingStepUpScope !== undefined && 1270: this._pendingStepUpScope.split(' ').some(s => !currentScopes.includes(s)) 1271: if (needsStepUp) { 1272: logMCPDebug( 1273: this.serverName, 1274: `Step-up pending (${this._pendingStepUpScope}), omitting refresh_token`, 1275: ) 1276: } 1277: // If token is expired and we don't have a refresh token, return undefined 1278: if (expiresIn <= 0 && !tokenData.refreshToken) { 1279: logMCPDebug(this.serverName, `Token expired without refresh token`) 1280: return undefined 1281: } 1282: // If token is expired or about to expire (within 5 minutes) and we have a refresh token, refresh it proactively. 1283: // This proactive refresh is a UX improvement - it avoids the latency of a failed request followed by token refresh. 1284: // While MCP servers should return 401 for expired tokens (which triggers SDK-level refresh), proactively refreshing 1285: // before expiry provides a smoother user experience. 1286: // Skip when step-up is pending — refreshing can't elevate scope (RFC 6749 §6). 1287: if (expiresIn <= 300 && tokenData.refreshToken && !needsStepUp) { 1288: // Reuse existing refresh promise if one is in progress to prevent concurrent refreshes 1289: if (!this._refreshInProgress) { 1290: logMCPDebug( 1291: this.serverName, 1292: `Token expires in ${Math.floor(expiresIn)}s, attempting proactive refresh`, 1293: ) 1294: this._refreshInProgress = this.refreshAuthorization( 1295: tokenData.refreshToken, 1296: ).finally(() => { 1297: this._refreshInProgress = undefined 1298: }) 1299: } else { 1300: logMCPDebug( 1301: this.serverName, 1302: `Token refresh already in progress, reusing existing promise`, 1303: ) 1304: } 1305: try { 1306: const refreshed = await this._refreshInProgress 1307: if (refreshed) { 1308: logMCPDebug(this.serverName, `Token refreshed successfully`) 1309: return refreshed 1310: } 1311: logMCPDebug( 1312: this.serverName, 1313: `Token refresh failed, returning current tokens`, 1314: ) 1315: } catch (error) { 1316: logMCPDebug( 1317: this.serverName, 1318: `Token refresh error: ${errorMessage(error)}`, 1319: ) 1320: } 1321: } 1322: // Return current tokens (may be expired if refresh failed or not needed yet) 1323: const tokens = { 1324: access_token: tokenData.accessToken, 1325: refresh_token: needsStepUp ? undefined : tokenData.refreshToken, 1326: expires_in: expiresIn, 1327: scope: tokenData.scope, 1328: token_type: 'Bearer', 1329: } 1330: logMCPDebug(this.serverName, `Returning tokens`) 1331: logMCPDebug(this.serverName, `Token length: ${tokens.access_token?.length}`) 1332: logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`) 1333: logMCPDebug(this.serverName, `Expires in: ${Math.floor(expiresIn)}s`) 1334: return tokens 1335: } 1336: async saveTokens(tokens: OAuthTokens): Promise<void> { 1337: this._pendingStepUpScope = undefined 1338: const storage = getSecureStorage() 1339: const existingData = storage.read() || {} 1340: const serverKey = getServerKey(this.serverName, this.serverConfig) 1341: logMCPDebug(this.serverName, `Saving tokens`) 1342: logMCPDebug(this.serverName, `Token expires in: ${tokens.expires_in}`) 1343: logMCPDebug(this.serverName, `Has refresh token: ${!!tokens.refresh_token}`) 1344: const updatedData: SecureStorageData = { 1345: ...existingData, 1346: mcpOAuth: { 1347: ...existingData.mcpOAuth, 1348: [serverKey]: { 1349: ...existingData.mcpOAuth?.[serverKey], 1350: serverName: this.serverName, 1351: serverUrl: this.serverConfig.url, 1352: accessToken: tokens.access_token, 1353: refreshToken: tokens.refresh_token, 1354: expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, 1355: scope: tokens.scope, 1356: }, 1357: }, 1358: } 1359: storage.update(updatedData) 1360: } 1361: /** 1362: * XAA silent refresh: cached id_token → Layer-2 exchange → new access_token. 1363: * No browser. 1364: * 1365: * Returns undefined if the id_token is gone from cache — caller treats this 1366: * as needs-interactive-reauth (transport will 401, CC surfaces it). 1367: * 1368: * On exchange failure, clears the id_token cache so the next interactive 1369: * auth does a fresh IdP login (the cached id_token is likely stale/revoked). 1370: * 1371: * TODO(xaa-ga): add cross-process lockfile before GA. `_refreshInProgress` 1372: * only dedupes within one process — two CC instances with expiring tokens 1373: * both fire the full 4-request XAA chain and race on storage.update(). 1374: * Unlike inc-4829 the id_token is not single-use so both access_tokens 1375: * stay valid (wasted round-trips + keychain write race, not brickage), 1376: * but this is the shape CLAUDE.md flags under "Token/auth caching across 1377: * process boundaries". Mirror refreshAuthorization()'s lockfile pattern. 1378: */ 1379: private async xaaRefresh(): Promise<OAuthTokens | undefined> { 1380: const idp = getXaaIdpSettings() 1381: if (!idp) return undefined // config was removed mid-session 1382: const idToken = getCachedIdpIdToken(idp.issuer) 1383: if (!idToken) { 1384: logMCPDebug( 1385: this.serverName, 1386: 'XAA: id_token not cached, needs interactive re-auth', 1387: ) 1388: return undefined 1389: } 1390: const clientId = this.serverConfig.oauth?.clientId 1391: const clientConfig = getMcpClientConfig(this.serverName, this.serverConfig) 1392: if (!clientId || !clientConfig?.clientSecret) { 1393: logMCPDebug( 1394: this.serverName, 1395: 'XAA: missing clientId or clientSecret in config — skipping silent refresh', 1396: ) 1397: return undefined 1398: } 1399: const idpClientSecret = getIdpClientSecret(idp.issuer) 1400: let oidc 1401: try { 1402: oidc = await discoverOidc(idp.issuer) 1403: } catch (e) { 1404: logMCPDebug( 1405: this.serverName, 1406: `XAA: OIDC discovery failed in silent refresh: ${errorMessage(e)}`, 1407: ) 1408: return undefined 1409: } 1410: try { 1411: const tokens = await performCrossAppAccess( 1412: this.serverConfig.url, 1413: { 1414: clientId, 1415: clientSecret: clientConfig.clientSecret, 1416: idpClientId: idp.clientId, 1417: idpClientSecret, 1418: idpIdToken: idToken, 1419: idpTokenEndpoint: oidc.token_endpoint, 1420: }, 1421: this.serverName, 1422: ) 1423: const storage = getSecureStorage() 1424: const existingData = storage.read() || {} 1425: const serverKey = getServerKey(this.serverName, this.serverConfig) 1426: const prev = existingData.mcpOAuth?.[serverKey] 1427: storage.update({ 1428: ...existingData, 1429: mcpOAuth: { 1430: ...existingData.mcpOAuth, 1431: [serverKey]: { 1432: ...prev, 1433: serverName: this.serverName, 1434: serverUrl: this.serverConfig.url, 1435: accessToken: tokens.access_token, 1436: refreshToken: tokens.refresh_token ?? prev?.refreshToken, 1437: expiresAt: Date.now() + (tokens.expires_in || 3600) * 1000, 1438: scope: tokens.scope, 1439: clientId, 1440: clientSecret: clientConfig.clientSecret, 1441: discoveryState: { 1442: authorizationServerUrl: tokens.authorizationServerUrl, 1443: }, 1444: }, 1445: }, 1446: }) 1447: return { 1448: access_token: tokens.access_token, 1449: token_type: 'Bearer', 1450: expires_in: tokens.expires_in, 1451: scope: tokens.scope, 1452: refresh_token: tokens.refresh_token, 1453: } 1454: } catch (e) { 1455: if (e instanceof XaaTokenExchangeError && e.shouldClearIdToken) { 1456: clearIdpIdToken(idp.issuer) 1457: logMCPDebug( 1458: this.serverName, 1459: 'XAA: cleared id_token after exchange failure', 1460: ) 1461: } 1462: throw e 1463: } 1464: } 1465: async redirectToAuthorization(authorizationUrl: URL): Promise<void> { 1466: this._authorizationUrl = authorizationUrl.toString() 1467: const scopes = authorizationUrl.searchParams.get('scope') 1468: logMCPDebug( 1469: this.serverName, 1470: `Authorization URL: ${redactSensitiveUrlParams(authorizationUrl.toString())}`, 1471: ) 1472: logMCPDebug(this.serverName, `Scopes in URL: ${scopes || 'NOT FOUND'}`) 1473: if (scopes) { 1474: this._scopes = scopes 1475: logMCPDebug( 1476: this.serverName, 1477: `Captured scopes from authorization URL: ${scopes}`, 1478: ) 1479: } else { 1480: const metadataScope = getScopeFromMetadata(this._metadata) 1481: if (metadataScope) { 1482: this._scopes = metadataScope 1483: logMCPDebug( 1484: this.serverName, 1485: `Using scopes from metadata: ${metadataScope}`, 1486: ) 1487: } else { 1488: logMCPDebug(this.serverName, `No scopes available from URL or metadata`) 1489: } 1490: } 1491: if (this._scopes && !this.handleRedirection) { 1492: const storage = getSecureStorage() 1493: const existingData = storage.read() || {} 1494: const serverKey = getServerKey(this.serverName, this.serverConfig) 1495: const existing = existingData.mcpOAuth?.[serverKey] 1496: if (existing) { 1497: existing.stepUpScope = this._scopes 1498: storage.update(existingData) 1499: logMCPDebug(this.serverName, `Persisted step-up scope: ${this._scopes}`) 1500: } 1501: } 1502: if (!this.handleRedirection) { 1503: logMCPDebug( 1504: this.serverName, 1505: `Redirection handling is disabled, skipping redirect`, 1506: ) 1507: return 1508: } 1509: const urlString = authorizationUrl.toString() 1510: if (!urlString.startsWith('http://') && !urlString.startsWith('https://')) { 1511: throw new Error( 1512: 'Invalid authorization URL: must use http:// or https:// scheme', 1513: ) 1514: } 1515: logMCPDebug(this.serverName, `Redirecting to authorization URL`) 1516: const redactedUrl = redactSensitiveUrlParams(urlString) 1517: logMCPDebug(this.serverName, `Authorization URL: ${redactedUrl}`) 1518: if (this.onAuthorizationUrlCallback) { 1519: this.onAuthorizationUrlCallback(urlString) 1520: } 1521: if (!this.skipBrowserOpen) { 1522: logMCPDebug(this.serverName, `Opening authorization URL: ${redactedUrl}`) 1523: const success = await openBrowser(urlString) 1524: if (!success) { 1525: logMCPDebug( 1526: this.serverName, 1527: `Browser didn't open automatically. URL is shown in UI.`, 1528: ) 1529: } 1530: } else { 1531: logMCPDebug( 1532: this.serverName, 1533: `Skipping browser open (skipBrowserOpen=true). URL: ${redactedUrl}`, 1534: ) 1535: } 1536: } 1537: async saveCodeVerifier(codeVerifier: string): Promise<void> { 1538: logMCPDebug(this.serverName, `Saving code verifier`) 1539: this._codeVerifier = codeVerifier 1540: } 1541: async codeVerifier(): Promise<string> { 1542: if (!this._codeVerifier) { 1543: logMCPDebug(this.serverName, `No code verifier saved`) 1544: throw new Error('No code verifier saved') 1545: } 1546: logMCPDebug(this.serverName, `Returning code verifier`) 1547: return this._codeVerifier 1548: } 1549: async invalidateCredentials( 1550: scope: 'all' | 'client' | 'tokens' | 'verifier' | 'discovery', 1551: ): Promise<void> { 1552: const storage = getSecureStorage() 1553: const existingData = storage.read() 1554: if (!existingData?.mcpOAuth) return 1555: const serverKey = getServerKey(this.serverName, this.serverConfig) 1556: const tokenData = existingData.mcpOAuth[serverKey] 1557: if (!tokenData) return 1558: switch (scope) { 1559: case 'all': 1560: delete existingData.mcpOAuth[serverKey] 1561: break 1562: case 'client': 1563: tokenData.clientId = undefined 1564: tokenData.clientSecret = undefined 1565: break 1566: case 'tokens': 1567: tokenData.accessToken = '' 1568: tokenData.refreshToken = undefined 1569: tokenData.expiresAt = 0 1570: break 1571: case 'verifier': 1572: this._codeVerifier = undefined 1573: return 1574: case 'discovery': 1575: tokenData.discoveryState = undefined 1576: tokenData.stepUpScope = undefined 1577: break 1578: } 1579: storage.update(existingData) 1580: logMCPDebug(this.serverName, `Invalidated credentials (scope: ${scope})`) 1581: } 1582: async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> { 1583: const storage = getSecureStorage() 1584: const existingData = storage.read() || {} 1585: const serverKey = getServerKey(this.serverName, this.serverConfig) 1586: logMCPDebug( 1587: this.serverName, 1588: `Saving discovery state (authServer: ${state.authorizationServerUrl})`, 1589: ) 1590: const updatedData: SecureStorageData = { 1591: ...existingData, 1592: mcpOAuth: { 1593: ...existingData.mcpOAuth, 1594: [serverKey]: { 1595: ...existingData.mcpOAuth?.[serverKey], 1596: serverName: this.serverName, 1597: serverUrl: this.serverConfig.url, 1598: accessToken: existingData.mcpOAuth?.[serverKey]?.accessToken || '', 1599: expiresAt: existingData.mcpOAuth?.[serverKey]?.expiresAt || 0, 1600: discoveryState: { 1601: authorizationServerUrl: state.authorizationServerUrl, 1602: resourceMetadataUrl: state.resourceMetadataUrl, 1603: }, 1604: }, 1605: }, 1606: } 1607: storage.update(updatedData) 1608: } 1609: async discoveryState(): Promise<OAuthDiscoveryState | undefined> { 1610: const storage = getSecureStorage() 1611: const data = storage.read() 1612: const serverKey = getServerKey(this.serverName, this.serverConfig) 1613: const cached = data?.mcpOAuth?.[serverKey]?.discoveryState 1614: if (cached?.authorizationServerUrl) { 1615: logMCPDebug( 1616: this.serverName, 1617: `Returning cached discovery state (authServer: ${cached.authorizationServerUrl})`, 1618: ) 1619: return { 1620: authorizationServerUrl: cached.authorizationServerUrl, 1621: resourceMetadataUrl: cached.resourceMetadataUrl, 1622: resourceMetadata: 1623: cached.resourceMetadata as OAuthDiscoveryState['resourceMetadata'], 1624: authorizationServerMetadata: 1625: cached.authorizationServerMetadata as OAuthDiscoveryState['authorizationServerMetadata'], 1626: } 1627: } 1628: const metadataUrl = this.serverConfig.oauth?.authServerMetadataUrl 1629: if (metadataUrl) { 1630: logMCPDebug( 1631: this.serverName, 1632: `Fetching metadata from configured URL: ${metadataUrl}`, 1633: ) 1634: try { 1635: const metadata = await fetchAuthServerMetadata( 1636: this.serverName, 1637: this.serverConfig.url, 1638: metadataUrl, 1639: ) 1640: if (metadata) { 1641: return { 1642: authorizationServerUrl: metadata.issuer, 1643: authorizationServerMetadata: 1644: metadata as OAuthDiscoveryState['authorizationServerMetadata'], 1645: } 1646: } 1647: } catch (error) { 1648: logMCPDebug( 1649: this.serverName, 1650: `Failed to fetch from configured metadata URL: ${errorMessage(error)}`, 1651: ) 1652: } 1653: } 1654: return undefined 1655: } 1656: async refreshAuthorization( 1657: refreshToken: string, 1658: ): Promise<OAuthTokens | undefined> { 1659: const serverKey = getServerKey(this.serverName, this.serverConfig) 1660: const claudeDir = getClaudeConfigHomeDir() 1661: await mkdir(claudeDir, { recursive: true }) 1662: const sanitizedKey = serverKey.replace(/[^a-zA-Z0-9]/g, '_') 1663: const lockfilePath = join(claudeDir, `mcp-refresh-${sanitizedKey}.lock`) 1664: let release: (() => Promise<void>) | undefined 1665: for (let retry = 0; retry < MAX_LOCK_RETRIES; retry++) { 1666: try { 1667: logMCPDebug( 1668: this.serverName, 1669: `Acquiring refresh lock (attempt ${retry + 1})`, 1670: ) 1671: release = await lockfile.lock(lockfilePath, { 1672: realpath: false, 1673: onCompromised: () => { 1674: logMCPDebug(this.serverName, `Refresh lock was compromised`) 1675: }, 1676: }) 1677: logMCPDebug(this.serverName, `Acquired refresh lock`) 1678: break 1679: } catch (e: unknown) { 1680: const code = getErrnoCode(e) 1681: if (code === 'ELOCKED') { 1682: logMCPDebug( 1683: this.serverName, 1684: `Refresh lock held by another process, waiting (attempt ${retry + 1}/${MAX_LOCK_RETRIES})`, 1685: ) 1686: await sleep(1000 + Math.random() * 1000) 1687: continue 1688: } 1689: logMCPDebug( 1690: this.serverName, 1691: `Failed to acquire refresh lock: ${code}, proceeding without lock`, 1692: ) 1693: break 1694: } 1695: } 1696: if (!release) { 1697: logMCPDebug( 1698: this.serverName, 1699: `Could not acquire refresh lock after ${MAX_LOCK_RETRIES} retries, proceeding without lock`, 1700: ) 1701: } 1702: try { 1703: clearKeychainCache() 1704: const storage = getSecureStorage() 1705: const data = storage.read() 1706: const tokenData = data?.mcpOAuth?.[serverKey] 1707: if (tokenData) { 1708: const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 1709: if (expiresIn > 300) { 1710: logMCPDebug( 1711: this.serverName, 1712: `Another process already refreshed tokens (expires in ${Math.floor(expiresIn)}s)`, 1713: ) 1714: return { 1715: access_token: tokenData.accessToken, 1716: refresh_token: tokenData.refreshToken, 1717: expires_in: expiresIn, 1718: scope: tokenData.scope, 1719: token_type: 'Bearer', 1720: } 1721: } 1722: if (tokenData.refreshToken) { 1723: refreshToken = tokenData.refreshToken 1724: } 1725: } 1726: return await this._doRefresh(refreshToken) 1727: } finally { 1728: if (release) { 1729: try { 1730: await release() 1731: logMCPDebug(this.serverName, `Released refresh lock`) 1732: } catch { 1733: logMCPDebug(this.serverName, `Failed to release refresh lock`) 1734: } 1735: } 1736: } 1737: } 1738: private async _doRefresh( 1739: refreshToken: string, 1740: ): Promise<OAuthTokens | undefined> { 1741: const MAX_ATTEMPTS = 3 1742: const mcpServerBaseUrl = getLoggingSafeMcpBaseUrl(this.serverConfig) 1743: const emitRefreshEvent = ( 1744: outcome: 'success' | 'failure', 1745: reason?: MCPRefreshFailureReason, 1746: ): void => { 1747: logEvent( 1748: outcome === 'success' 1749: ? 'tengu_mcp_oauth_refresh_success' 1750: : 'tengu_mcp_oauth_refresh_failure', 1751: { 1752: transportType: this.serverConfig 1753: .type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1754: ...(mcpServerBaseUrl 1755: ? { 1756: mcpServerBaseUrl: 1757: mcpServerBaseUrl as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1758: } 1759: : {}), 1760: ...(reason 1761: ? { 1762: reason: 1763: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1764: } 1765: : {}), 1766: }, 1767: ) 1768: } 1769: for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { 1770: try { 1771: logMCPDebug(this.serverName, `Starting token refresh`) 1772: const authFetch = createAuthFetch() 1773: let metadata = this._metadata 1774: if (!metadata) { 1775: const cached = await this.discoveryState() 1776: if (cached?.authorizationServerMetadata) { 1777: logMCPDebug( 1778: this.serverName, 1779: `Using persisted auth server metadata for refresh`, 1780: ) 1781: metadata = cached.authorizationServerMetadata 1782: } else if (cached?.authorizationServerUrl) { 1783: logMCPDebug( 1784: this.serverName, 1785: `Re-discovering metadata from persisted auth server URL: ${cached.authorizationServerUrl}`, 1786: ) 1787: metadata = await discoverAuthorizationServerMetadata( 1788: cached.authorizationServerUrl, 1789: { fetchFn: authFetch }, 1790: ) 1791: } 1792: } 1793: if (!metadata) { 1794: metadata = await fetchAuthServerMetadata( 1795: this.serverName, 1796: this.serverConfig.url, 1797: this.serverConfig.oauth?.authServerMetadataUrl, 1798: authFetch, 1799: ) 1800: } 1801: if (!metadata) { 1802: logMCPDebug(this.serverName, `Failed to discover OAuth metadata`) 1803: emitRefreshEvent('failure', 'metadata_discovery_failed') 1804: return undefined 1805: } 1806: this._metadata = metadata 1807: const clientInfo = await this.clientInformation() 1808: if (!clientInfo) { 1809: logMCPDebug(this.serverName, `No client information available`) 1810: emitRefreshEvent('failure', 'no_client_info') 1811: return undefined 1812: } 1813: const newTokens = await sdkRefreshAuthorization( 1814: new URL(this.serverConfig.url), 1815: { 1816: metadata, 1817: clientInformation: clientInfo, 1818: refreshToken, 1819: resource: new URL(this.serverConfig.url), 1820: fetchFn: authFetch, 1821: }, 1822: ) 1823: if (newTokens) { 1824: logMCPDebug(this.serverName, `Token refresh successful`) 1825: await this.saveTokens(newTokens) 1826: emitRefreshEvent('success') 1827: return newTokens 1828: } 1829: logMCPDebug(this.serverName, `Token refresh returned no tokens`) 1830: emitRefreshEvent('failure', 'no_tokens_returned') 1831: return undefined 1832: } catch (error) { 1833: if (error instanceof InvalidGrantError) { 1834: logMCPDebug( 1835: this.serverName, 1836: `Token refresh failed with invalid_grant: ${error.message}`, 1837: ) 1838: clearKeychainCache() 1839: const storage = getSecureStorage() 1840: const data = storage.read() 1841: const serverKey = getServerKey(this.serverName, this.serverConfig) 1842: const tokenData = data?.mcpOAuth?.[serverKey] 1843: if (tokenData) { 1844: const expiresIn = (tokenData.expiresAt - Date.now()) / 1000 1845: if (expiresIn > 300) { 1846: logMCPDebug( 1847: this.serverName, 1848: `Another process refreshed tokens, using those`, 1849: ) 1850: return { 1851: access_token: tokenData.accessToken, 1852: refresh_token: tokenData.refreshToken, 1853: expires_in: expiresIn, 1854: scope: tokenData.scope, 1855: token_type: 'Bearer', 1856: } 1857: } 1858: } 1859: logMCPDebug( 1860: this.serverName, 1861: `No valid tokens in storage, clearing stored tokens`, 1862: ) 1863: await this.invalidateCredentials('tokens') 1864: emitRefreshEvent('failure', 'invalid_grant') 1865: return undefined 1866: } 1867: const isTimeoutError = 1868: error instanceof Error && 1869: /timeout|timed out|etimedout|econnreset/i.test(error.message) 1870: const isTransientServerError = 1871: error instanceof ServerError || 1872: error instanceof TemporarilyUnavailableError || 1873: error instanceof TooManyRequestsError 1874: const isRetryable = isTimeoutError || isTransientServerError 1875: if (!isRetryable || attempt >= MAX_ATTEMPTS) { 1876: logMCPDebug( 1877: this.serverName, 1878: `Token refresh failed: ${errorMessage(error)}`, 1879: ) 1880: emitRefreshEvent( 1881: 'failure', 1882: isRetryable ? 'transient_retries_exhausted' : 'request_failed', 1883: ) 1884: return undefined 1885: } 1886: const delayMs = 1000 * Math.pow(2, attempt - 1) 1887: logMCPDebug( 1888: this.serverName, 1889: `Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`, 1890: ) 1891: await sleep(delayMs) 1892: } 1893: } 1894: return undefined 1895: } 1896: } 1897: export async function readClientSecret(): Promise<string> { 1898: const envSecret = process.env.MCP_CLIENT_SECRET 1899: if (envSecret) { 1900: return envSecret 1901: } 1902: if (!process.stdin.isTTY) { 1903: throw new Error( 1904: 'No TTY available to prompt for client secret. Set MCP_CLIENT_SECRET env var instead.', 1905: ) 1906: } 1907: return new Promise((resolve, reject) => { 1908: process.stderr.write('Enter OAuth client secret: ') 1909: process.stdin.setRawMode?.(true) 1910: let secret = '' 1911: const onData = (ch: Buffer) => { 1912: const c = ch.toString() 1913: if (c === '\n' || c === '\r') { 1914: process.stdin.setRawMode?.(false) 1915: process.stdin.removeListener('data', onData) 1916: process.stderr.write('\n') 1917: resolve(secret) 1918: } else if (c === '\u0003') { 1919: process.stdin.setRawMode?.(false) 1920: process.stdin.removeListener('data', onData) 1921: reject(new Error('Cancelled')) 1922: } else if (c === '\u007F' || c === '\b') { 1923: secret = secret.slice(0, -1) 1924: } else { 1925: secret += c 1926: } 1927: } 1928: process.stdin.on('data', onData) 1929: }) 1930: } 1931: export function saveMcpClientSecret( 1932: serverName: string, 1933: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 1934: clientSecret: string, 1935: ): void { 1936: const storage = getSecureStorage() 1937: const existingData = storage.read() || {} 1938: const serverKey = getServerKey(serverName, serverConfig) 1939: storage.update({ 1940: ...existingData, 1941: mcpOAuthClientConfig: { 1942: ...existingData.mcpOAuthClientConfig, 1943: [serverKey]: { clientSecret }, 1944: }, 1945: }) 1946: } 1947: export function clearMcpClientConfig( 1948: serverName: string, 1949: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 1950: ): void { 1951: const storage = getSecureStorage() 1952: const existingData = storage.read() 1953: if (!existingData?.mcpOAuthClientConfig) return 1954: const serverKey = getServerKey(serverName, serverConfig) 1955: if (existingData.mcpOAuthClientConfig[serverKey]) { 1956: delete existingData.mcpOAuthClientConfig[serverKey] 1957: storage.update(existingData) 1958: } 1959: } 1960: export function getMcpClientConfig( 1961: serverName: string, 1962: serverConfig: McpSSEServerConfig | McpHTTPServerConfig, 1963: ): { clientSecret?: string } | undefined { 1964: const storage = getSecureStorage() 1965: const data = storage.read() 1966: const serverKey = getServerKey(serverName, serverConfig) 1967: return data?.mcpOAuthClientConfig?.[serverKey] 1968: } 1969: function getScopeFromMetadata( 1970: metadata: AuthorizationServerMetadata | undefined, 1971: ): string | undefined { 1972: if (!metadata) return undefined 1973: if ('scope' in metadata && typeof metadata.scope === 'string') { 1974: return metadata.scope 1975: } 1976: if ( 1977: 'default_scope' in metadata && 1978: typeof metadata.default_scope === 'string' 1979: ) { 1980: return metadata.default_scope 1981: } 1982: if (metadata.scopes_supported && Array.isArray(metadata.scopes_supported)) { 1983: return metadata.scopes_supported.join(' ') 1984: } 1985: return undefined 1986: }

File: src/services/mcp/channelAllowlist.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 5: export type ChannelAllowlistEntry = { 6: marketplace: string 7: plugin: string 8: } 9: const ChannelAllowlistSchema = lazySchema(() => 10: z.array( 11: z.object({ 12: marketplace: z.string(), 13: plugin: z.string(), 14: }), 15: ), 16: ) 17: export function getChannelAllowlist(): ChannelAllowlistEntry[] { 18: const raw = getFeatureValue_CACHED_MAY_BE_STALE<unknown>( 19: 'tengu_harbor_ledger', 20: [], 21: ) 22: const parsed = ChannelAllowlistSchema().safeParse(raw) 23: return parsed.success ? parsed.data : [] 24: } 25: export function isChannelsEnabled(): boolean { 26: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor', false) 27: } 28: export function isChannelAllowlisted( 29: pluginSource: string | undefined, 30: ): boolean { 31: if (!pluginSource) return false 32: const { name, marketplace } = parsePluginIdentifier(pluginSource) 33: if (!marketplace) return false 34: return getChannelAllowlist().some( 35: e => e.plugin === name && e.marketplace === marketplace, 36: ) 37: }

File: src/services/mcp/channelNotification.ts

typescript 1: import type { ServerCapabilities } from '@modelcontextprotocol/sdk/types.js' 2: import { z } from 'zod/v4' 3: import { type ChannelEntry, getAllowedChannels } from '../../bootstrap/state.js' 4: import { CHANNEL_TAG } from '../../constants/xml.js' 5: import { 6: getClaudeAIOAuthTokens, 7: getSubscriptionType, 8: } from '../../utils/auth.js' 9: import { lazySchema } from '../../utils/lazySchema.js' 10: import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' 11: import { getSettingsForSource } from '../../utils/settings/settings.js' 12: import { escapeXmlAttr } from '../../utils/xml.js' 13: import { 14: type ChannelAllowlistEntry, 15: getChannelAllowlist, 16: isChannelsEnabled, 17: } from './channelAllowlist.js' 18: export const ChannelMessageNotificationSchema = lazySchema(() => 19: z.object({ 20: method: z.literal('notifications/claude/channel'), 21: params: z.object({ 22: content: z.string(), 23: meta: z.record(z.string(), z.string()).optional(), 24: }), 25: }), 26: ) 27: export const CHANNEL_PERMISSION_METHOD = 28: 'notifications/claude/channel/permission' 29: export const ChannelPermissionNotificationSchema = lazySchema(() => 30: z.object({ 31: method: z.literal(CHANNEL_PERMISSION_METHOD), 32: params: z.object({ 33: request_id: z.string(), 34: behavior: z.enum(['allow', 'deny']), 35: }), 36: }), 37: ) 38: export const CHANNEL_PERMISSION_REQUEST_METHOD = 39: 'notifications/claude/channel/permission_request' 40: export type ChannelPermissionRequestParams = { 41: request_id: string 42: tool_name: string 43: description: string 44: input_preview: string 45: } 46: const SAFE_META_KEY = /^[a-zA-Z_][a-zA-Z0-9_]*$/ 47: export function wrapChannelMessage( 48: serverName: string, 49: content: string, 50: meta?: Record<string, string>, 51: ): string { 52: const attrs = Object.entries(meta ?? {}) 53: .filter(([k]) => SAFE_META_KEY.test(k)) 54: .map(([k, v]) => ` ${k}="${escapeXmlAttr(v)}"`) 55: .join('') 56: return `<${CHANNEL_TAG} source="${escapeXmlAttr(serverName)}"${attrs}>\n${content}\n</${CHANNEL_TAG}>` 57: } 58: /** 59: * Effective allowlist for the current session. Team/enterprise orgs can set 60: * allowedChannelPlugins in managed settings — when set, it REPLACES the 61: * GrowthBook ledger (admin owns the trust decision). Undefined falls back 62: * to the ledger. Unmanaged users always get the ledger. 63: * 64: * Callers already read sub/policy for the policy gate — pass them in to 65: * avoid double-reading getSettingsForSource (uncached). 66: */ 67: export function getEffectiveChannelAllowlist( 68: sub: ReturnType<typeof getSubscriptionType>, 69: orgList: ChannelAllowlistEntry[] | undefined, 70: ): { 71: entries: ChannelAllowlistEntry[] 72: source: 'org' | 'ledger' 73: } { 74: if ((sub === 'team' || sub === 'enterprise') && orgList) { 75: return { entries: orgList, source: 'org' } 76: } 77: return { entries: getChannelAllowlist(), source: 'ledger' } 78: } 79: export type ChannelGateResult = 80: | { action: 'register' } 81: | { 82: action: 'skip' 83: kind: 84: | 'capability' 85: | 'disabled' 86: | 'auth' 87: | 'policy' 88: | 'session' 89: | 'marketplace' 90: | 'allowlist' 91: reason: string 92: } 93: export function findChannelEntry( 94: serverName: string, 95: channels: readonly ChannelEntry[], 96: ): ChannelEntry | undefined { 97: const parts = serverName.split(':') 98: return channels.find(c => 99: c.kind === 'server' 100: ? serverName === c.name 101: : parts[0] === 'plugin' && parts[1] === c.name, 102: ) 103: } 104: export function gateChannelServer( 105: serverName: string, 106: capabilities: ServerCapabilities | undefined, 107: pluginSource: string | undefined, 108: ): ChannelGateResult { 109: if (!capabilities?.experimental?.['claude/channel']) { 110: return { 111: action: 'skip', 112: kind: 'capability', 113: reason: 'server did not declare claude/channel capability', 114: } 115: } 116: if (!isChannelsEnabled()) { 117: return { 118: action: 'skip', 119: kind: 'disabled', 120: reason: 'channels feature is not currently available', 121: } 122: } 123: if (!getClaudeAIOAuthTokens()?.accessToken) { 124: return { 125: action: 'skip', 126: kind: 'auth', 127: reason: 'channels requires claude.ai authentication (run /login)', 128: } 129: } 130: const sub = getSubscriptionType() 131: const managed = sub === 'team' || sub === 'enterprise' 132: const policy = managed ? getSettingsForSource('policySettings') : undefined 133: if (managed && policy?.channelsEnabled !== true) { 134: return { 135: action: 'skip', 136: kind: 'policy', 137: reason: 138: 'channels not enabled by org policy (set channelsEnabled: true in managed settings)', 139: } 140: } 141: const entry = findChannelEntry(serverName, getAllowedChannels()) 142: if (!entry) { 143: return { 144: action: 'skip', 145: kind: 'session', 146: reason: `server ${serverName} not in --channels list for this session`, 147: } 148: } 149: if (entry.kind === 'plugin') { 150: const actual = pluginSource 151: ? parsePluginIdentifier(pluginSource).marketplace 152: : undefined 153: if (actual !== entry.marketplace) { 154: return { 155: action: 'skip', 156: kind: 'marketplace', 157: reason: `you asked for plugin:${entry.name}@${entry.marketplace} but the installed ${entry.name} plugin is from ${actual ?? 'an unknown source'}`, 158: } 159: } 160: if (!entry.dev) { 161: const { entries, source } = getEffectiveChannelAllowlist( 162: sub, 163: policy?.allowedChannelPlugins, 164: ) 165: if ( 166: !entries.some( 167: e => e.plugin === entry.name && e.marketplace === entry.marketplace, 168: ) 169: ) { 170: return { 171: action: 'skip', 172: kind: 'allowlist', 173: reason: 174: source === 'org' 175: ? `plugin ${entry.name}@${entry.marketplace} is not on your org's approved channels list (set allowedChannelPlugins in managed settings)` 176: : `plugin ${entry.name}@${entry.marketplace} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`, 177: } 178: } 179: } 180: } else { 181: if (!entry.dev) { 182: return { 183: action: 'skip', 184: kind: 'allowlist', 185: reason: `server ${entry.name} is not on the approved channels allowlist (use --dangerously-load-development-channels for local dev)`, 186: } 187: } 188: } 189: return { action: 'register' } 190: }

File: src/services/mcp/channelPermissions.ts

typescript 1: import { jsonStringify } from '../../utils/slowOperations.js' 2: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 3: export function isChannelPermissionRelayEnabled(): boolean { 4: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_harbor_permissions', false) 5: } 6: export type ChannelPermissionResponse = { 7: behavior: 'allow' | 'deny' 8: fromServer: string 9: } 10: export type ChannelPermissionCallbacks = { 11: onResponse( 12: requestId: string, 13: handler: (response: ChannelPermissionResponse) => void, 14: ): () => void 15: resolve( 16: requestId: string, 17: behavior: 'allow' | 'deny', 18: fromServer: string, 19: ): boolean 20: } 21: export const PERMISSION_REPLY_RE = /^\s*(y|yes|n|no)\s+([a-km-z]{5})\s*$/i 22: const ID_ALPHABET = 'abcdefghijkmnopqrstuvwxyz' 23: const ID_AVOID_SUBSTRINGS = [ 24: 'fuck', 25: 'shit', 26: 'cunt', 27: 'cock', 28: 'dick', 29: 'twat', 30: 'piss', 31: 'crap', 32: 'bitch', 33: 'whore', 34: 'ass', 35: 'tit', 36: 'cum', 37: 'fag', 38: 'dyke', 39: 'nig', 40: 'kike', 41: 'rape', 42: 'nazi', 43: 'damn', 44: 'poo', 45: 'pee', 46: 'wank', 47: 'anus', 48: ] 49: function hashToId(input: string): string { 50: let h = 0x811c9dc5 51: for (let i = 0; i < input.length; i++) { 52: h ^= input.charCodeAt(i) 53: h = Math.imul(h, 0x01000193) 54: } 55: h = h >>> 0 56: let s = '' 57: for (let i = 0; i < 5; i++) { 58: s += ID_ALPHABET[h % 25] 59: h = Math.floor(h / 25) 60: } 61: return s 62: } 63: /** 64: * Short ID from a toolUseID. 5 letters from a 25-char alphabet (a-z minus 65: * 'l' — looks like 1/I in many fonts). 25^5 ≈ 9.8M space, birthday 66: * collision at 50% needs ~3K simultaneous pending prompts, absurd for a 67: * single interactive session. Letters-only so phone users don't switch 68: * keyboard modes (hex alternates a-f/0-9 → mode toggles). Re-hashes with 69: * a salt suffix if the result contains a blocklisted substring — 5 random 70: * letters can spell things you don't want in a text message to your phone. 71: * toolUseIDs are `toolu_` + base64-ish; we hash rather than slice. 72: */ 73: export function shortRequestId(toolUseID: string): string { 74: let candidate = hashToId(toolUseID) 75: for (let salt = 0; salt < 10; salt++) { 76: if (!ID_AVOID_SUBSTRINGS.some(bad => candidate.includes(bad))) { 77: return candidate 78: } 79: candidate = hashToId(`${toolUseID}:${salt}`) 80: } 81: return candidate 82: } 83: export function truncateForPreview(input: unknown): string { 84: try { 85: const s = jsonStringify(input) 86: return s.length > 200 ? s.slice(0, 200) + '…' : s 87: } catch { 88: return '(unserializable)' 89: } 90: } 91: export function filterPermissionRelayClients< 92: T extends { 93: type: string 94: name: string 95: capabilities?: { experimental?: Record<string, unknown> } 96: }, 97: >( 98: clients: readonly T[], 99: isInAllowlist: (name: string) => boolean, 100: ): (T & { type: 'connected' })[] { 101: return clients.filter( 102: (c): c is T & { type: 'connected' } => 103: c.type === 'connected' && 104: isInAllowlist(c.name) && 105: c.capabilities?.experimental?.['claude/channel'] !== undefined && 106: c.capabilities?.experimental?.['claude/channel/permission'] !== undefined, 107: ) 108: } 109: export function createChannelPermissionCallbacks(): ChannelPermissionCallbacks { 110: const pending = new Map< 111: string, 112: (response: ChannelPermissionResponse) => void 113: >() 114: return { 115: onResponse(requestId, handler) { 116: const key = requestId.toLowerCase() 117: pending.set(key, handler) 118: return () => { 119: pending.delete(key) 120: } 121: }, 122: resolve(requestId, behavior, fromServer) { 123: const key = requestId.toLowerCase() 124: const resolver = pending.get(key) 125: if (!resolver) return false 126: pending.delete(key) 127: resolver({ behavior, fromServer }) 128: return true 129: }, 130: } 131: }

File: src/services/mcp/claudeai.ts

typescript 1: import axios from 'axios' 2: import memoize from 'lodash-es/memoize.js' 3: import { getOauthConfig } from 'src/constants/oauth.js' 4: import { 5: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6: logEvent, 7: } from 'src/services/analytics/index.js' 8: import { getClaudeAIOAuthTokens } from 'src/utils/auth.js' 9: import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' 10: import { logForDebugging } from 'src/utils/debug.js' 11: import { isEnvDefinedFalsy } from 'src/utils/envUtils.js' 12: import { clearMcpAuthCache } from './client.js' 13: import { normalizeNameForMCP } from './normalization.js' 14: import type { ScopedMcpServerConfig } from './types.js' 15: type ClaudeAIMcpServer = { 16: type: 'mcp_server' 17: id: string 18: display_name: string 19: url: string 20: created_at: string 21: } 22: type ClaudeAIMcpServersResponse = { 23: data: ClaudeAIMcpServer[] 24: has_more: boolean 25: next_page: string | null 26: } 27: const FETCH_TIMEOUT_MS = 5000 28: const MCP_SERVERS_BETA_HEADER = 'mcp-servers-2025-12-04' 29: export const fetchClaudeAIMcpConfigsIfEligible = memoize( 30: async (): Promise<Record<string, ScopedMcpServerConfig>> => { 31: try { 32: if (isEnvDefinedFalsy(process.env.ENABLE_CLAUDEAI_MCP_SERVERS)) { 33: logForDebugging('[claudeai-mcp] Disabled via env var') 34: logEvent('tengu_claudeai_mcp_eligibility', { 35: state: 36: 'disabled_env_var' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 37: }) 38: return {} 39: } 40: const tokens = getClaudeAIOAuthTokens() 41: if (!tokens?.accessToken) { 42: logForDebugging('[claudeai-mcp] No access token') 43: logEvent('tengu_claudeai_mcp_eligibility', { 44: state: 45: 'no_oauth_token' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 46: }) 47: return {} 48: } 49: if (!tokens.scopes?.includes('user:mcp_servers')) { 50: logForDebugging( 51: `[claudeai-mcp] Missing user:mcp_servers scope (scopes=${tokens.scopes?.join(',') || 'none'})`, 52: ) 53: logEvent('tengu_claudeai_mcp_eligibility', { 54: state: 55: 'missing_scope' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 56: }) 57: return {} 58: } 59: const baseUrl = getOauthConfig().BASE_API_URL 60: const url = `${baseUrl}/v1/mcp_servers?limit=1000` 61: logForDebugging(`[claudeai-mcp] Fetching from ${url}`) 62: const response = await axios.get<ClaudeAIMcpServersResponse>(url, { 63: headers: { 64: Authorization: `Bearer ${tokens.accessToken}`, 65: 'Content-Type': 'application/json', 66: 'anthropic-beta': MCP_SERVERS_BETA_HEADER, 67: 'anthropic-version': '2023-06-01', 68: }, 69: timeout: FETCH_TIMEOUT_MS, 70: }) 71: const configs: Record<string, ScopedMcpServerConfig> = {} 72: const usedNormalizedNames = new Set<string>() 73: for (const server of response.data.data) { 74: const baseName = `claude.ai ${server.display_name}` 75: let finalName = baseName 76: let finalNormalized = normalizeNameForMCP(finalName) 77: let count = 1 78: while (usedNormalizedNames.has(finalNormalized)) { 79: count++ 80: finalName = `${baseName} (${count})` 81: finalNormalized = normalizeNameForMCP(finalName) 82: } 83: usedNormalizedNames.add(finalNormalized) 84: configs[finalName] = { 85: type: 'claudeai-proxy', 86: url: server.url, 87: id: server.id, 88: scope: 'claudeai', 89: } 90: } 91: logForDebugging( 92: `[claudeai-mcp] Fetched ${Object.keys(configs).length} servers`, 93: ) 94: logEvent('tengu_claudeai_mcp_eligibility', { 95: state: 96: 'eligible' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 97: }) 98: return configs 99: } catch { 100: logForDebugging(`[claudeai-mcp] Fetch failed`) 101: return {} 102: } 103: }, 104: ) 105: export function clearClaudeAIMcpConfigsCache(): void { 106: fetchClaudeAIMcpConfigsIfEligible.cache.clear?.() 107: clearMcpAuthCache() 108: } 109: export function markClaudeAiMcpConnected(name: string): void { 110: saveGlobalConfig(current => { 111: const seen = current.claudeAiMcpEverConnected ?? [] 112: if (seen.includes(name)) return current 113: return { ...current, claudeAiMcpEverConnected: [...seen, name] } 114: }) 115: } 116: export function hasClaudeAiMcpEverConnected(name: string): boolean { 117: return (getGlobalConfig().claudeAiMcpEverConnected ?? []).includes(name) 118: }

File: src/services/mcp/client.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { 3: Base64ImageSource, 4: ContentBlockParam, 5: MessageParam, 6: } from '@anthropic-ai/sdk/resources/index.mjs' 7: import { Client } from '@modelcontextprotocol/sdk/client/index.js' 8: import { 9: SSEClientTransport, 10: type SSEClientTransportOptions, 11: } from '@modelcontextprotocol/sdk/client/sse.js' 12: import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' 13: import { 14: StreamableHTTPClientTransport, 15: type StreamableHTTPClientTransportOptions, 16: } from '@modelcontextprotocol/sdk/client/streamableHttp.js' 17: import { 18: createFetchWithInit, 19: type FetchLike, 20: type Transport, 21: } from '@modelcontextprotocol/sdk/shared/transport.js' 22: import { 23: CallToolResultSchema, 24: ElicitRequestSchema, 25: type ElicitRequestURLParams, 26: type ElicitResult, 27: ErrorCode, 28: type JSONRPCMessage, 29: type ListPromptsResult, 30: ListPromptsResultSchema, 31: ListResourcesResultSchema, 32: ListRootsRequestSchema, 33: type ListToolsResult, 34: ListToolsResultSchema, 35: McpError, 36: type PromptMessage, 37: type ResourceLink, 38: } from '@modelcontextprotocol/sdk/types.js' 39: import mapValues from 'lodash-es/mapValues.js' 40: import memoize from 'lodash-es/memoize.js' 41: import zipObject from 'lodash-es/zipObject.js' 42: import pMap from 'p-map' 43: import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js' 44: import type { Command } from '../../commands.js' 45: import { getOauthConfig } from '../../constants/oauth.js' 46: import { PRODUCT_URL } from '../../constants/product.js' 47: import type { AppState } from '../../state/AppState.js' 48: import { 49: type Tool, 50: type ToolCallProgress, 51: toolMatchesName, 52: } from '../../Tool.js' 53: import { ListMcpResourcesTool } from '../../tools/ListMcpResourcesTool/ListMcpResourcesTool.js' 54: import { type MCPProgress, MCPTool } from '../../tools/MCPTool/MCPTool.js' 55: import { createMcpAuthTool } from '../../tools/McpAuthTool/McpAuthTool.js' 56: import { ReadMcpResourceTool } from '../../tools/ReadMcpResourceTool/ReadMcpResourceTool.js' 57: import { createAbortController } from '../../utils/abortController.js' 58: import { count } from '../../utils/array.js' 59: import { 60: checkAndRefreshOAuthTokenIfNeeded, 61: getClaudeAIOAuthTokens, 62: handleOAuth401Error, 63: } from '../../utils/auth.js' 64: import { registerCleanup } from '../../utils/cleanupRegistry.js' 65: import { detectCodeIndexingFromMcpServerName } from '../../utils/codeIndexing.js' 66: import { logForDebugging } from '../../utils/debug.js' 67: import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js' 68: import { 69: errorMessage, 70: TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 71: } from '../../utils/errors.js' 72: import { getMCPUserAgent } from '../../utils/http.js' 73: import { maybeNotifyIDEConnected } from '../../utils/ide.js' 74: import { maybeResizeAndDownsampleImageBuffer } from '../../utils/imageResizer.js' 75: import { logMCPDebug, logMCPError } from '../../utils/log.js' 76: import { 77: getBinaryBlobSavedMessage, 78: getFormatDescription, 79: getLargeOutputInstructions, 80: persistBinaryContent, 81: } from '../../utils/mcpOutputStorage.js' 82: import { 83: getContentSizeEstimate, 84: type MCPToolResult, 85: mcpContentNeedsTruncation, 86: truncateMcpContentIfNeeded, 87: } from '../../utils/mcpValidation.js' 88: import { WebSocketTransport } from '../../utils/mcpWebSocketTransport.js' 89: import { memoizeWithLRU } from '../../utils/memoize.js' 90: import { getWebSocketTLSOptions } from '../../utils/mtls.js' 91: import { 92: getProxyFetchOptions, 93: getWebSocketProxyAgent, 94: getWebSocketProxyUrl, 95: } from '../../utils/proxy.js' 96: import { recursivelySanitizeUnicode } from '../../utils/sanitization.js' 97: import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' 98: import { subprocessEnv } from '../../utils/subprocessEnv.js' 99: import { 100: isPersistError, 101: persistToolResult, 102: } from '../../utils/toolResultStorage.js' 103: import { 104: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 105: logEvent, 106: } from '../analytics/index.js' 107: import { 108: type ElicitationWaitingState, 109: runElicitationHooks, 110: runElicitationResultHooks, 111: } from './elicitationHandler.js' 112: import { buildMcpToolName } from './mcpStringUtils.js' 113: import { normalizeNameForMCP } from './normalization.js' 114: import { getLoggingSafeMcpBaseUrl } from './utils.js' 115: const fetchMcpSkillsForClient = feature('MCP_SKILLS') 116: ? ( 117: require('../../skills/mcpSkills.js') as typeof import('../../skills/mcpSkills.js') 118: ).fetchMcpSkillsForClient 119: : null 120: import { UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' 121: import type { AssistantMessage } from 'src/types/message.js' 122: import { classifyMcpToolForCollapse } from '../../tools/MCPTool/classifyForCollapse.js' 123: import { clearKeychainCache } from '../../utils/secureStorage/macOsKeychainHelpers.js' 124: import { sleep } from '../../utils/sleep.js' 125: import { 126: ClaudeAuthProvider, 127: hasMcpDiscoveryButNoToken, 128: wrapFetchWithStepUpDetection, 129: } from './auth.js' 130: import { markClaudeAiMcpConnected } from './claudeai.js' 131: import { getAllMcpConfigs, isMcpServerDisabled } from './config.js' 132: import { getMcpServerHeaders } from './headersHelper.js' 133: import { SdkControlClientTransport } from './SdkControlTransport.js' 134: import type { 135: ConnectedMCPServer, 136: MCPServerConnection, 137: McpSdkServerConfig, 138: ScopedMcpServerConfig, 139: ServerResource, 140: } from './types.js' 141: export class McpAuthError extends Error { 142: serverName: string 143: constructor(serverName: string, message: string) { 144: super(message) 145: this.name = 'McpAuthError' 146: this.serverName = serverName 147: } 148: } 149: class McpSessionExpiredError extends Error { 150: constructor(serverName: string) { 151: super(`MCP server "${serverName}" session expired`) 152: this.name = 'McpSessionExpiredError' 153: } 154: } 155: export class McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS extends TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { 156: constructor( 157: message: string, 158: telemetryMessage: string, 159: readonly mcpMeta?: { _meta?: Record<string, unknown> }, 160: ) { 161: super(message, telemetryMessage) 162: this.name = 'McpToolCallError' 163: } 164: } 165: export function isMcpSessionExpiredError(error: Error): boolean { 166: const httpStatus = 167: 'code' in error ? (error as Error & { code?: number }).code : undefined 168: if (httpStatus !== 404) { 169: return false 170: } 171: return ( 172: error.message.includes('"code":-32001') || 173: error.message.includes('"code": -32001') 174: ) 175: } 176: const DEFAULT_MCP_TOOL_TIMEOUT_MS = 100_000_000 177: const MAX_MCP_DESCRIPTION_LENGTH = 2048 178: function getMcpToolTimeoutMs(): number { 179: return ( 180: parseInt(process.env.MCP_TOOL_TIMEOUT || '', 10) || 181: DEFAULT_MCP_TOOL_TIMEOUT_MS 182: ) 183: } 184: import { isClaudeInChromeMCPServer } from '../../utils/claudeInChrome/common.js' 185: const claudeInChromeToolRendering = 186: (): typeof import('../../utils/claudeInChrome/toolRendering.js') => 187: require('../../utils/claudeInChrome/toolRendering.js') 188: const computerUseWrapper = feature('CHICAGO_MCP') 189: ? (): typeof import('../../utils/computerUse/wrapper.js') => 190: require('../../utils/computerUse/wrapper.js') 191: : undefined 192: const isComputerUseMCPServer = feature('CHICAGO_MCP') 193: ? ( 194: require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') 195: ).isComputerUseMCPServer 196: : undefined 197: import { mkdir, readFile, unlink, writeFile } from 'fs/promises' 198: import { dirname, join } from 'path' 199: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 200: import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' 201: const MCP_AUTH_CACHE_TTL_MS = 15 * 60 * 1000 202: type McpAuthCacheData = Record<string, { timestamp: number }> 203: function getMcpAuthCachePath(): string { 204: return join(getClaudeConfigHomeDir(), 'mcp-needs-auth-cache.json') 205: } 206: let authCachePromise: Promise<McpAuthCacheData> | null = null 207: function getMcpAuthCache(): Promise<McpAuthCacheData> { 208: if (!authCachePromise) { 209: authCachePromise = readFile(getMcpAuthCachePath(), 'utf-8') 210: .then(data => jsonParse(data) as McpAuthCacheData) 211: .catch(() => ({})) 212: } 213: return authCachePromise 214: } 215: async function isMcpAuthCached(serverId: string): Promise<boolean> { 216: const cache = await getMcpAuthCache() 217: const entry = cache[serverId] 218: if (!entry) { 219: return false 220: } 221: return Date.now() - entry.timestamp < MCP_AUTH_CACHE_TTL_MS 222: } 223: let writeChain = Promise.resolve() 224: function setMcpAuthCacheEntry(serverId: string): void { 225: writeChain = writeChain 226: .then(async () => { 227: const cache = await getMcpAuthCache() 228: cache[serverId] = { timestamp: Date.now() } 229: const cachePath = getMcpAuthCachePath() 230: await mkdir(dirname(cachePath), { recursive: true }) 231: await writeFile(cachePath, jsonStringify(cache)) 232: authCachePromise = null 233: }) 234: .catch(() => { 235: }) 236: } 237: export function clearMcpAuthCache(): void { 238: authCachePromise = null 239: void unlink(getMcpAuthCachePath()).catch(() => { 240: }) 241: } 242: function mcpBaseUrlAnalytics(serverRef: ScopedMcpServerConfig): { 243: mcpServerBaseUrl?: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 244: } { 245: const url = getLoggingSafeMcpBaseUrl(serverRef) 246: return url 247: ? { 248: mcpServerBaseUrl: 249: url as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 250: } 251: : {} 252: } 253: function handleRemoteAuthFailure( 254: name: string, 255: serverRef: ScopedMcpServerConfig, 256: transportType: 'sse' | 'http' | 'claudeai-proxy', 257: ): MCPServerConnection { 258: logEvent('tengu_mcp_server_needs_auth', { 259: transportType: 260: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 261: ...mcpBaseUrlAnalytics(serverRef), 262: }) 263: const label: Record<typeof transportType, string> = { 264: sse: 'SSE', 265: http: 'HTTP', 266: 'claudeai-proxy': 'claude.ai proxy', 267: } 268: logMCPDebug( 269: name, 270: `Authentication required for ${label[transportType]} server`, 271: ) 272: setMcpAuthCacheEntry(name) 273: return { name, type: 'needs-auth', config: serverRef } 274: } 275: export function createClaudeAiProxyFetch(innerFetch: FetchLike): FetchLike { 276: return async (url, init) => { 277: const doRequest = async () => { 278: await checkAndRefreshOAuthTokenIfNeeded() 279: const currentTokens = getClaudeAIOAuthTokens() 280: if (!currentTokens) { 281: throw new Error('No claude.ai OAuth token available') 282: } 283: const headers = new Headers(init?.headers) 284: headers.set('Authorization', `Bearer ${currentTokens.accessToken}`) 285: const response = await innerFetch(url, { ...init, headers }) 286: return { response, sentToken: currentTokens.accessToken } 287: } 288: const { response, sentToken } = await doRequest() 289: if (response.status !== 401) { 290: return response 291: } 292: const tokenChanged = await handleOAuth401Error(sentToken).catch(() => false) 293: logEvent('tengu_mcp_claudeai_proxy_401', { 294: tokenChanged: 295: tokenChanged as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 296: }) 297: if (!tokenChanged) { 298: const now = getClaudeAIOAuthTokens()?.accessToken 299: if (!now || now === sentToken) { 300: return response 301: } 302: } 303: try { 304: return (await doRequest()).response 305: } catch { 306: return response 307: } 308: } 309: } 310: type WsClientLike = { 311: readonly readyState: number 312: close(): void 313: send(data: string): void 314: } 315: async function createNodeWsClient( 316: url: string, 317: options: Record<string, unknown>, 318: ): Promise<WsClientLike> { 319: const wsModule = await import('ws') 320: const WS = wsModule.default as unknown as new ( 321: url: string, 322: protocols: string[], 323: options: Record<string, unknown>, 324: ) => WsClientLike 325: return new WS(url, ['mcp'], options) 326: } 327: const IMAGE_MIME_TYPES = new Set([ 328: 'image/jpeg', 329: 'image/png', 330: 'image/gif', 331: 'image/webp', 332: ]) 333: function getConnectionTimeoutMs(): number { 334: return parseInt(process.env.MCP_TIMEOUT || '', 10) || 30000 335: } 336: /** 337: * Default timeout for individual MCP requests (auth, tool calls, etc.) 338: */ 339: const MCP_REQUEST_TIMEOUT_MS = 60000 340: /** 341: * MCP Streamable HTTP spec requires clients to advertise acceptance of both 342: * JSON and SSE on every POST. Servers that enforce this strictly reject 343: * requests without it (HTTP 406). 344: * https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#sending-messages-to-the-server 345: */ 346: const MCP_STREAMABLE_HTTP_ACCEPT = 'application/json, text/event-stream' 347: export function wrapFetchWithTimeout(baseFetch: FetchLike): FetchLike { 348: return async (url: string | URL, init?: RequestInit) => { 349: const method = (init?.method ?? 'GET').toUpperCase() 350: if (method === 'GET') { 351: return baseFetch(url, init) 352: } 353: const headers = new Headers(init?.headers) 354: if (!headers.has('accept')) { 355: headers.set('accept', MCP_STREAMABLE_HTTP_ACCEPT) 356: } 357: const controller = new AbortController() 358: const timer = setTimeout( 359: c => 360: c.abort(new DOMException('The operation timed out.', 'TimeoutError')), 361: MCP_REQUEST_TIMEOUT_MS, 362: controller, 363: ) 364: timer.unref?.() 365: const parentSignal = init?.signal 366: const abort = () => controller.abort(parentSignal?.reason) 367: parentSignal?.addEventListener('abort', abort) 368: if (parentSignal?.aborted) { 369: controller.abort(parentSignal.reason) 370: } 371: const cleanup = () => { 372: clearTimeout(timer) 373: parentSignal?.removeEventListener('abort', abort) 374: } 375: try { 376: const response = await baseFetch(url, { 377: ...init, 378: headers, 379: signal: controller.signal, 380: }) 381: cleanup() 382: return response 383: } catch (error) { 384: cleanup() 385: throw error 386: } 387: } 388: } 389: export function getMcpServerConnectionBatchSize(): number { 390: return parseInt(process.env.MCP_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 3 391: } 392: function getRemoteMcpServerConnectionBatchSize(): number { 393: return ( 394: parseInt(process.env.MCP_REMOTE_SERVER_CONNECTION_BATCH_SIZE || '', 10) || 395: 20 396: ) 397: } 398: function isLocalMcpServer(config: ScopedMcpServerConfig): boolean { 399: return !config.type || config.type === 'stdio' || config.type === 'sdk' 400: } 401: const ALLOWED_IDE_TOOLS = ['mcp__ide__executeCode', 'mcp__ide__getDiagnostics'] 402: function isIncludedMcpTool(tool: Tool): boolean { 403: return ( 404: !tool.name.startsWith('mcp__ide__') || ALLOWED_IDE_TOOLS.includes(tool.name) 405: ) 406: } 407: export function getServerCacheKey( 408: name: string, 409: serverRef: ScopedMcpServerConfig, 410: ): string { 411: return `${name}-${jsonStringify(serverRef)}` 412: } 413: export const connectToServer = memoize( 414: async ( 415: name: string, 416: serverRef: ScopedMcpServerConfig, 417: serverStats?: { 418: totalServers: number 419: stdioCount: number 420: sseCount: number 421: httpCount: number 422: sseIdeCount: number 423: wsIdeCount: number 424: }, 425: ): Promise<MCPServerConnection> => { 426: const connectStartTime = Date.now() 427: let inProcessServer: 428: | { connect(t: Transport): Promise<void>; close(): Promise<void> } 429: | undefined 430: try { 431: let transport 432: const sessionIngressToken = getSessionIngressAuthToken() 433: if (serverRef.type === 'sse') { 434: const authProvider = new ClaudeAuthProvider(name, serverRef) 435: const combinedHeaders = await getMcpServerHeaders(name, serverRef) 436: const transportOptions: SSEClientTransportOptions = { 437: authProvider, 438: fetch: wrapFetchWithTimeout( 439: wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), 440: ), 441: requestInit: { 442: headers: { 443: 'User-Agent': getMCPUserAgent(), 444: ...combinedHeaders, 445: }, 446: }, 447: } 448: transportOptions.eventSourceInit = { 449: fetch: async (url: string | URL, init?: RequestInit) => { 450: const authHeaders: Record<string, string> = {} 451: const tokens = await authProvider.tokens() 452: if (tokens) { 453: authHeaders.Authorization = `Bearer ${tokens.access_token}` 454: } 455: const proxyOptions = getProxyFetchOptions() 456: return fetch(url, { 457: ...init, 458: ...proxyOptions, 459: headers: { 460: 'User-Agent': getMCPUserAgent(), 461: ...authHeaders, 462: ...init?.headers, 463: ...combinedHeaders, 464: Accept: 'text/event-stream', 465: }, 466: }) 467: }, 468: } 469: transport = new SSEClientTransport( 470: new URL(serverRef.url), 471: transportOptions, 472: ) 473: logMCPDebug(name, `SSE transport initialized, awaiting connection`) 474: } else if (serverRef.type === 'sse-ide') { 475: logMCPDebug(name, `Setting up SSE-IDE transport to ${serverRef.url}`) 476: const proxyOptions = getProxyFetchOptions() 477: const transportOptions: SSEClientTransportOptions = 478: proxyOptions.dispatcher 479: ? { 480: eventSourceInit: { 481: fetch: async (url: string | URL, init?: RequestInit) => { 482: return fetch(url, { 483: ...init, 484: ...proxyOptions, 485: headers: { 486: 'User-Agent': getMCPUserAgent(), 487: ...init?.headers, 488: }, 489: }) 490: }, 491: }, 492: } 493: : {} 494: transport = new SSEClientTransport( 495: new URL(serverRef.url), 496: Object.keys(transportOptions).length > 0 497: ? transportOptions 498: : undefined, 499: ) 500: } else if (serverRef.type === 'ws-ide') { 501: const tlsOptions = getWebSocketTLSOptions() 502: const wsHeaders = { 503: 'User-Agent': getMCPUserAgent(), 504: ...(serverRef.authToken && { 505: 'X-Claude-Code-Ide-Authorization': serverRef.authToken, 506: }), 507: } 508: let wsClient: WsClientLike 509: if (typeof Bun !== 'undefined') { 510: wsClient = new globalThis.WebSocket(serverRef.url, { 511: protocols: ['mcp'], 512: headers: wsHeaders, 513: proxy: getWebSocketProxyUrl(serverRef.url), 514: tls: tlsOptions || undefined, 515: } as unknown as string[]) 516: } else { 517: wsClient = await createNodeWsClient(serverRef.url, { 518: headers: wsHeaders, 519: agent: getWebSocketProxyAgent(serverRef.url), 520: ...(tlsOptions || {}), 521: }) 522: } 523: transport = new WebSocketTransport(wsClient) 524: } else if (serverRef.type === 'ws') { 525: logMCPDebug( 526: name, 527: `Initializing WebSocket transport to ${serverRef.url}`, 528: ) 529: const combinedHeaders = await getMcpServerHeaders(name, serverRef) 530: const tlsOptions = getWebSocketTLSOptions() 531: const wsHeaders = { 532: 'User-Agent': getMCPUserAgent(), 533: ...(sessionIngressToken && { 534: Authorization: `Bearer ${sessionIngressToken}`, 535: }), 536: ...combinedHeaders, 537: } 538: const wsHeadersForLogging = mapValues(wsHeaders, (value, key) => 539: key.toLowerCase() === 'authorization' ? '[REDACTED]' : value, 540: ) 541: logMCPDebug( 542: name, 543: `WebSocket transport options: ${jsonStringify({ 544: url: serverRef.url, 545: headers: wsHeadersForLogging, 546: hasSessionAuth: !!sessionIngressToken, 547: })}`, 548: ) 549: let wsClient: WsClientLike 550: if (typeof Bun !== 'undefined') { 551: wsClient = new globalThis.WebSocket(serverRef.url, { 552: protocols: ['mcp'], 553: headers: wsHeaders, 554: proxy: getWebSocketProxyUrl(serverRef.url), 555: tls: tlsOptions || undefined, 556: } as unknown as string[]) 557: } else { 558: wsClient = await createNodeWsClient(serverRef.url, { 559: headers: wsHeaders, 560: agent: getWebSocketProxyAgent(serverRef.url), 561: ...(tlsOptions || {}), 562: }) 563: } 564: transport = new WebSocketTransport(wsClient) 565: } else if (serverRef.type === 'http') { 566: logMCPDebug(name, `Initializing HTTP transport to ${serverRef.url}`) 567: logMCPDebug( 568: name, 569: `Node version: ${process.version}, Platform: ${process.platform}`, 570: ) 571: logMCPDebug( 572: name, 573: `Environment: ${jsonStringify({ 574: NODE_OPTIONS: process.env.NODE_OPTIONS || 'not set', 575: UV_THREADPOOL_SIZE: process.env.UV_THREADPOOL_SIZE || 'default', 576: HTTP_PROXY: process.env.HTTP_PROXY || 'not set', 577: HTTPS_PROXY: process.env.HTTPS_PROXY || 'not set', 578: NO_PROXY: process.env.NO_PROXY || 'not set', 579: })}`, 580: ) 581: const authProvider = new ClaudeAuthProvider(name, serverRef) 582: const combinedHeaders = await getMcpServerHeaders(name, serverRef) 583: const hasOAuthTokens = !!(await authProvider.tokens()) 584: const proxyOptions = getProxyFetchOptions() 585: logMCPDebug( 586: name, 587: `Proxy options: ${proxyOptions.dispatcher ? 'custom dispatcher' : 'default'}`, 588: ) 589: const transportOptions: StreamableHTTPClientTransportOptions = { 590: authProvider, 591: fetch: wrapFetchWithTimeout( 592: wrapFetchWithStepUpDetection(createFetchWithInit(), authProvider), 593: ), 594: requestInit: { 595: ...proxyOptions, 596: headers: { 597: 'User-Agent': getMCPUserAgent(), 598: ...(sessionIngressToken && 599: !hasOAuthTokens && { 600: Authorization: `Bearer ${sessionIngressToken}`, 601: }), 602: ...combinedHeaders, 603: }, 604: }, 605: } 606: const headersForLogging = transportOptions.requestInit?.headers 607: ? mapValues( 608: transportOptions.requestInit.headers as Record<string, string>, 609: (value, key) => 610: key.toLowerCase() === 'authorization' ? '[REDACTED]' : value, 611: ) 612: : undefined 613: logMCPDebug( 614: name, 615: `HTTP transport options: ${jsonStringify({ 616: url: serverRef.url, 617: headers: headersForLogging, 618: hasAuthProvider: !!authProvider, 619: timeoutMs: MCP_REQUEST_TIMEOUT_MS, 620: })}`, 621: ) 622: transport = new StreamableHTTPClientTransport( 623: new URL(serverRef.url), 624: transportOptions, 625: ) 626: logMCPDebug(name, `HTTP transport created successfully`) 627: } else if (serverRef.type === 'sdk') { 628: throw new Error('SDK servers should be handled in print.ts') 629: } else if (serverRef.type === 'claudeai-proxy') { 630: logMCPDebug( 631: name, 632: `Initializing claude.ai proxy transport for server ${serverRef.id}`, 633: ) 634: const tokens = getClaudeAIOAuthTokens() 635: if (!tokens) { 636: throw new Error('No claude.ai OAuth token found') 637: } 638: const oauthConfig = getOauthConfig() 639: const proxyUrl = `${oauthConfig.MCP_PROXY_URL}${oauthConfig.MCP_PROXY_PATH.replace('{server_id}', serverRef.id)}` 640: logMCPDebug(name, `Using claude.ai proxy at ${proxyUrl}`) 641: const fetchWithAuth = createClaudeAiProxyFetch(globalThis.fetch) 642: const proxyOptions = getProxyFetchOptions() 643: const transportOptions: StreamableHTTPClientTransportOptions = { 644: fetch: wrapFetchWithTimeout(fetchWithAuth), 645: requestInit: { 646: ...proxyOptions, 647: headers: { 648: 'User-Agent': getMCPUserAgent(), 649: 'X-Mcp-Client-Session-Id': getSessionId(), 650: }, 651: }, 652: } 653: transport = new StreamableHTTPClientTransport( 654: new URL(proxyUrl), 655: transportOptions, 656: ) 657: logMCPDebug(name, `claude.ai proxy transport created successfully`) 658: } else if ( 659: (serverRef.type === 'stdio' || !serverRef.type) && 660: isClaudeInChromeMCPServer(name) 661: ) { 662: const { createChromeContext } = await import( 663: '../../utils/claudeInChrome/mcpServer.js' 664: ) 665: const { createClaudeForChromeMcpServer } = await import( 666: '@ant/claude-for-chrome-mcp' 667: ) 668: const { createLinkedTransportPair } = await import( 669: './InProcessTransport.js' 670: ) 671: const context = createChromeContext(serverRef.env) 672: inProcessServer = createClaudeForChromeMcpServer(context) 673: const [clientTransport, serverTransport] = createLinkedTransportPair() 674: await inProcessServer.connect(serverTransport) 675: transport = clientTransport 676: logMCPDebug(name, `In-process Chrome MCP server started`) 677: } else if ( 678: feature('CHICAGO_MCP') && 679: (serverRef.type === 'stdio' || !serverRef.type) && 680: isComputerUseMCPServer!(name) 681: ) { 682: const { createComputerUseMcpServerForCli } = await import( 683: '../../utils/computerUse/mcpServer.js' 684: ) 685: const { createLinkedTransportPair } = await import( 686: './InProcessTransport.js' 687: ) 688: inProcessServer = await createComputerUseMcpServerForCli() 689: const [clientTransport, serverTransport] = createLinkedTransportPair() 690: await inProcessServer.connect(serverTransport) 691: transport = clientTransport 692: logMCPDebug(name, `In-process Computer Use MCP server started`) 693: } else if (serverRef.type === 'stdio' || !serverRef.type) { 694: const finalCommand = 695: process.env.CLAUDE_CODE_SHELL_PREFIX || serverRef.command 696: const finalArgs = process.env.CLAUDE_CODE_SHELL_PREFIX 697: ? [[serverRef.command, ...serverRef.args].join(' ')] 698: : serverRef.args 699: transport = new StdioClientTransport({ 700: command: finalCommand, 701: args: finalArgs, 702: env: { 703: ...subprocessEnv(), 704: ...serverRef.env, 705: } as Record<string, string>, 706: stderr: 'pipe', 707: }) 708: } else { 709: throw new Error(`Unsupported server type: ${serverRef.type}`) 710: } 711: let stderrHandler: ((data: Buffer) => void) | undefined 712: let stderrOutput = '' 713: if (serverRef.type === 'stdio' || !serverRef.type) { 714: const stdioTransport = transport as StdioClientTransport 715: if (stdioTransport.stderr) { 716: stderrHandler = (data: Buffer) => { 717: if (stderrOutput.length < 64 * 1024 * 1024) { 718: try { 719: stderrOutput += data.toString() 720: } catch { 721: } 722: } 723: } 724: stdioTransport.stderr.on('data', stderrHandler) 725: } 726: } 727: const client = new Client( 728: { 729: name: 'claude-code', 730: title: 'Claude Code', 731: version: MACRO.VERSION ?? 'unknown', 732: description: "Anthropic's agentic coding tool", 733: websiteUrl: PRODUCT_URL, 734: }, 735: { 736: capabilities: { 737: roots: {}, 738: elicitation: {}, 739: }, 740: }, 741: ) 742: if (serverRef.type === 'http') { 743: logMCPDebug(name, `Client created, setting up request handler`) 744: } 745: client.setRequestHandler(ListRootsRequestSchema, async () => { 746: logMCPDebug(name, `Received ListRoots request from server`) 747: return { 748: roots: [ 749: { 750: uri: `file://${getOriginalCwd()}`, 751: }, 752: ], 753: } 754: }) 755: logMCPDebug( 756: name, 757: `Starting connection with timeout of ${getConnectionTimeoutMs()}ms`, 758: ) 759: if (serverRef.type === 'http') { 760: logMCPDebug(name, `Testing basic HTTP connectivity to ${serverRef.url}`) 761: try { 762: const testUrl = new URL(serverRef.url) 763: logMCPDebug( 764: name, 765: `Parsed URL: host=${testUrl.hostname}, port=${testUrl.port || 'default'}, protocol=${testUrl.protocol}`, 766: ) 767: if ( 768: testUrl.hostname === '127.0.0.1' || 769: testUrl.hostname === 'localhost' 770: ) { 771: logMCPDebug(name, `Using loopback address: ${testUrl.hostname}`) 772: } 773: } catch (urlError) { 774: logMCPDebug(name, `Failed to parse URL: ${urlError}`) 775: } 776: } 777: const connectPromise = client.connect(transport) 778: const timeoutPromise = new Promise<never>((_, reject) => { 779: const timeoutId = setTimeout(() => { 780: const elapsed = Date.now() - connectStartTime 781: logMCPDebug( 782: name, 783: `Connection timeout triggered after ${elapsed}ms (limit: ${getConnectionTimeoutMs()}ms)`, 784: ) 785: if (inProcessServer) { 786: inProcessServer.close().catch(() => {}) 787: } 788: transport.close().catch(() => {}) 789: reject( 790: new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 791: `MCP server "${name}" connection timed out after ${getConnectionTimeoutMs()}ms`, 792: 'MCP connection timeout', 793: ), 794: ) 795: }, getConnectionTimeoutMs()) 796: connectPromise.then( 797: () => { 798: clearTimeout(timeoutId) 799: }, 800: _error => { 801: clearTimeout(timeoutId) 802: }, 803: ) 804: }) 805: try { 806: await Promise.race([connectPromise, timeoutPromise]) 807: if (stderrOutput) { 808: logMCPError(name, `Server stderr: ${stderrOutput}`) 809: stderrOutput = '' // Release accumulated string to prevent memory growth 810: } 811: const elapsed = Date.now() - connectStartTime 812: logMCPDebug( 813: name, 814: `Successfully connected (transport: ${serverRef.type || 'stdio'}) in ${elapsed}ms`, 815: ) 816: } catch (error) { 817: const elapsed = Date.now() - connectStartTime 818: if (serverRef.type === 'sse' && error instanceof Error) { 819: logMCPDebug( 820: name, 821: `SSE Connection failed after ${elapsed}ms: ${jsonStringify({ 822: url: serverRef.url, 823: error: error.message, 824: errorType: error.constructor.name, 825: stack: error.stack, 826: })}`, 827: ) 828: logMCPError(name, error) 829: if (error instanceof UnauthorizedError) { 830: return handleRemoteAuthFailure(name, serverRef, 'sse') 831: } 832: } else if (serverRef.type === 'http' && error instanceof Error) { 833: const errorObj = error as Error & { 834: cause?: unknown 835: code?: string 836: errno?: string | number 837: syscall?: string 838: } 839: logMCPDebug( 840: name, 841: `HTTP Connection failed after ${elapsed}ms: ${error.message} (code: ${errorObj.code || 'none'}, errno: ${errorObj.errno || 'none'})`, 842: ) 843: logMCPError(name, error) 844: if (error instanceof UnauthorizedError) { 845: return handleRemoteAuthFailure(name, serverRef, 'http') 846: } 847: } else if ( 848: serverRef.type === 'claudeai-proxy' && 849: error instanceof Error 850: ) { 851: logMCPDebug( 852: name, 853: `claude.ai proxy connection failed after ${elapsed}ms: ${error.message}`, 854: ) 855: logMCPError(name, error) 856: const errorCode = (error as Error & { code?: number }).code 857: if (errorCode === 401) { 858: return handleRemoteAuthFailure(name, serverRef, 'claudeai-proxy') 859: } 860: } else if ( 861: serverRef.type === 'sse-ide' || 862: serverRef.type === 'ws-ide' 863: ) { 864: logEvent('tengu_mcp_ide_server_connection_failed', { 865: connectionDurationMs: elapsed, 866: }) 867: } 868: if (inProcessServer) { 869: inProcessServer.close().catch(() => {}) 870: } 871: transport.close().catch(() => {}) 872: if (stderrOutput) { 873: logMCPError(name, `Server stderr: ${stderrOutput}`) 874: } 875: throw error 876: } 877: const capabilities = client.getServerCapabilities() 878: const serverVersion = client.getServerVersion() 879: const rawInstructions = client.getInstructions() 880: let instructions = rawInstructions 881: if ( 882: rawInstructions && 883: rawInstructions.length > MAX_MCP_DESCRIPTION_LENGTH 884: ) { 885: instructions = 886: rawInstructions.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]' 887: logMCPDebug( 888: name, 889: `Server instructions truncated from ${rawInstructions.length} to ${MAX_MCP_DESCRIPTION_LENGTH} chars`, 890: ) 891: } 892: logMCPDebug( 893: name, 894: `Connection established with capabilities: ${jsonStringify({ 895: hasTools: !!capabilities?.tools, 896: hasPrompts: !!capabilities?.prompts, 897: hasResources: !!capabilities?.resources, 898: hasResourceSubscribe: !!capabilities?.resources?.subscribe, 899: serverVersion: serverVersion || 'unknown', 900: })}`, 901: ) 902: logForDebugging( 903: `[MCP] Server "${name}" connected with subscribe=${!!capabilities?.resources?.subscribe}`, 904: ) 905: client.setRequestHandler(ElicitRequestSchema, async request => { 906: logMCPDebug( 907: name, 908: `Elicitation request received during initialization: ${jsonStringify(request)}`, 909: ) 910: return { action: 'cancel' as const } 911: }) 912: if (serverRef.type === 'sse-ide' || serverRef.type === 'ws-ide') { 913: const ideConnectionDurationMs = Date.now() - connectStartTime 914: logEvent('tengu_mcp_ide_server_connection_succeeded', { 915: connectionDurationMs: ideConnectionDurationMs, 916: serverVersion: 917: serverVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 918: }) 919: try { 920: void maybeNotifyIDEConnected(client) 921: } catch (error) { 922: logMCPError( 923: name, 924: `Failed to send ide_connected notification: ${error}`, 925: ) 926: } 927: } 928: const connectionStartTime = Date.now() 929: let hasErrorOccurred = false 930: const originalOnerror = client.onerror 931: const originalOnclose = client.onclose 932: let consecutiveConnectionErrors = 0 933: const MAX_ERRORS_BEFORE_RECONNECT = 3 934: let hasTriggeredClose = false 935: const closeTransportAndRejectPending = (reason: string) => { 936: if (hasTriggeredClose) return 937: hasTriggeredClose = true 938: logMCPDebug(name, `Closing transport (${reason})`) 939: void client.close().catch(e => { 940: logMCPDebug(name, `Error during close: ${errorMessage(e)}`) 941: }) 942: } 943: const isTerminalConnectionError = (msg: string): boolean => { 944: return ( 945: msg.includes('ECONNRESET') || 946: msg.includes('ETIMEDOUT') || 947: msg.includes('EPIPE') || 948: msg.includes('EHOSTUNREACH') || 949: msg.includes('ECONNREFUSED') || 950: msg.includes('Body Timeout Error') || 951: msg.includes('terminated') || 952: msg.includes('SSE stream disconnected') || 953: msg.includes('Failed to reconnect SSE stream') 954: ) 955: } 956: client.onerror = (error: Error) => { 957: const uptime = Date.now() - connectionStartTime 958: hasErrorOccurred = true 959: const transportType = serverRef.type || 'stdio' 960: logMCPDebug( 961: name, 962: `${transportType.toUpperCase()} connection dropped after ${Math.floor(uptime / 1000)}s uptime`, 963: ) 964: if (error.message) { 965: if (error.message.includes('ECONNRESET')) { 966: logMCPDebug( 967: name, 968: `Connection reset - server may have crashed or restarted`, 969: ) 970: } else if (error.message.includes('ETIMEDOUT')) { 971: logMCPDebug( 972: name, 973: `Connection timeout - network issue or server unresponsive`, 974: ) 975: } else if (error.message.includes('ECONNREFUSED')) { 976: logMCPDebug(name, `Connection refused - server may be down`) 977: } else if (error.message.includes('EPIPE')) { 978: logMCPDebug( 979: name, 980: `Broken pipe - server closed connection unexpectedly`, 981: ) 982: } else if (error.message.includes('EHOSTUNREACH')) { 983: logMCPDebug(name, `Host unreachable - network connectivity issue`) 984: } else if (error.message.includes('ESRCH')) { 985: logMCPDebug( 986: name, 987: `Process not found - stdio server process terminated`, 988: ) 989: } else if (error.message.includes('spawn')) { 990: logMCPDebug( 991: name, 992: `Failed to spawn process - check command and permissions`, 993: ) 994: } else { 995: logMCPDebug(name, `Connection error: ${error.message}`) 996: } 997: } 998: if ( 999: (transportType === 'http' || transportType === 'claudeai-proxy') && 1000: isMcpSessionExpiredError(error) 1001: ) { 1002: logMCPDebug( 1003: name, 1004: `MCP session expired (server returned 404 with session-not-found), triggering reconnection`, 1005: ) 1006: closeTransportAndRejectPending('session expired') 1007: if (originalOnerror) { 1008: originalOnerror(error) 1009: } 1010: return 1011: } 1012: if ( 1013: transportType === 'sse' || 1014: transportType === 'http' || 1015: transportType === 'claudeai-proxy' 1016: ) { 1017: if (error.message.includes('Maximum reconnection attempts')) { 1018: closeTransportAndRejectPending('SSE reconnection exhausted') 1019: if (originalOnerror) { 1020: originalOnerror(error) 1021: } 1022: return 1023: } 1024: if (isTerminalConnectionError(error.message)) { 1025: consecutiveConnectionErrors++ 1026: logMCPDebug( 1027: name, 1028: `Terminal connection error ${consecutiveConnectionErrors}/${MAX_ERRORS_BEFORE_RECONNECT}`, 1029: ) 1030: if (consecutiveConnectionErrors >= MAX_ERRORS_BEFORE_RECONNECT) { 1031: consecutiveConnectionErrors = 0 1032: closeTransportAndRejectPending('max consecutive terminal errors') 1033: } 1034: } else { 1035: consecutiveConnectionErrors = 0 1036: } 1037: } 1038: if (originalOnerror) { 1039: originalOnerror(error) 1040: } 1041: } 1042: client.onclose = () => { 1043: const uptime = Date.now() - connectionStartTime 1044: const transportType = serverRef.type ?? 'unknown' 1045: logMCPDebug( 1046: name, 1047: `${transportType.toUpperCase()} connection closed after ${Math.floor(uptime / 1000)}s (${hasErrorOccurred ? 'with errors' : 'cleanly'})`, 1048: ) 1049: const key = getServerCacheKey(name, serverRef) 1050: fetchToolsForClient.cache.delete(name) 1051: fetchResourcesForClient.cache.delete(name) 1052: fetchCommandsForClient.cache.delete(name) 1053: if (feature('MCP_SKILLS')) { 1054: fetchMcpSkillsForClient!.cache.delete(name) 1055: } 1056: connectToServer.cache.delete(key) 1057: logMCPDebug(name, `Cleared connection cache for reconnection`) 1058: if (originalOnclose) { 1059: originalOnclose() 1060: } 1061: } 1062: const cleanup = async () => { 1063: if (inProcessServer) { 1064: try { 1065: await inProcessServer.close() 1066: } catch (error) { 1067: logMCPDebug(name, `Error closing in-process server: ${error}`) 1068: } 1069: try { 1070: await client.close() 1071: } catch (error) { 1072: logMCPDebug(name, `Error closing client: ${error}`) 1073: } 1074: return 1075: } 1076: if (stderrHandler && (serverRef.type === 'stdio' || !serverRef.type)) { 1077: const stdioTransport = transport as StdioClientTransport 1078: stdioTransport.stderr?.off('data', stderrHandler) 1079: } 1080: if (serverRef.type === 'stdio') { 1081: try { 1082: const stdioTransport = transport as StdioClientTransport 1083: const childPid = stdioTransport.pid 1084: if (childPid) { 1085: logMCPDebug(name, 'Sending SIGINT to MCP server process') 1086: try { 1087: process.kill(childPid, 'SIGINT') 1088: } catch (error) { 1089: logMCPDebug(name, `Error sending SIGINT: ${error}`) 1090: return 1091: } 1092: await new Promise<void>(async resolve => { 1093: let resolved = false 1094: const checkInterval = setInterval(() => { 1095: try { 1096: process.kill(childPid, 0) 1097: } catch { 1098: if (!resolved) { 1099: resolved = true 1100: clearInterval(checkInterval) 1101: clearTimeout(failsafeTimeout) 1102: logMCPDebug(name, 'MCP server process exited cleanly') 1103: resolve() 1104: } 1105: } 1106: }, 50) 1107: const failsafeTimeout = setTimeout(() => { 1108: if (!resolved) { 1109: resolved = true 1110: clearInterval(checkInterval) 1111: logMCPDebug( 1112: name, 1113: 'Cleanup timeout reached, stopping process monitoring', 1114: ) 1115: resolve() 1116: } 1117: }, 600) 1118: try { 1119: await sleep(100) 1120: if (!resolved) { 1121: try { 1122: process.kill(childPid, 0) 1123: logMCPDebug( 1124: name, 1125: 'SIGINT failed, sending SIGTERM to MCP server process', 1126: ) 1127: try { 1128: process.kill(childPid, 'SIGTERM') 1129: } catch (termError) { 1130: logMCPDebug(name, `Error sending SIGTERM: ${termError}`) 1131: resolved = true 1132: clearInterval(checkInterval) 1133: clearTimeout(failsafeTimeout) 1134: resolve() 1135: return 1136: } 1137: } catch { 1138: resolved = true 1139: clearInterval(checkInterval) 1140: clearTimeout(failsafeTimeout) 1141: resolve() 1142: return 1143: } 1144: await sleep(400) 1145: if (!resolved) { 1146: try { 1147: process.kill(childPid, 0) 1148: logMCPDebug( 1149: name, 1150: 'SIGTERM failed, sending SIGKILL to MCP server process', 1151: ) 1152: try { 1153: process.kill(childPid, 'SIGKILL') 1154: } catch (killError) { 1155: logMCPDebug( 1156: name, 1157: `Error sending SIGKILL: ${killError}`, 1158: ) 1159: } 1160: } catch { 1161: resolved = true 1162: clearInterval(checkInterval) 1163: clearTimeout(failsafeTimeout) 1164: resolve() 1165: } 1166: } 1167: } 1168: if (!resolved) { 1169: resolved = true 1170: clearInterval(checkInterval) 1171: clearTimeout(failsafeTimeout) 1172: resolve() 1173: } 1174: } catch { 1175: if (!resolved) { 1176: resolved = true 1177: clearInterval(checkInterval) 1178: clearTimeout(failsafeTimeout) 1179: resolve() 1180: } 1181: } 1182: }) 1183: } 1184: } catch (processError) { 1185: logMCPDebug(name, `Error terminating process: ${processError}`) 1186: } 1187: } 1188: try { 1189: await client.close() 1190: } catch (error) { 1191: logMCPDebug(name, `Error closing client: ${error}`) 1192: } 1193: } 1194: const cleanupUnregister = registerCleanup(cleanup) 1195: const wrappedCleanup = async () => { 1196: cleanupUnregister?.() 1197: await cleanup() 1198: } 1199: const connectionDurationMs = Date.now() - connectStartTime 1200: logEvent('tengu_mcp_server_connection_succeeded', { 1201: connectionDurationMs, 1202: transportType: (serverRef.type ?? 1203: 'stdio') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1204: totalServers: serverStats?.totalServers, 1205: stdioCount: serverStats?.stdioCount, 1206: sseCount: serverStats?.sseCount, 1207: httpCount: serverStats?.httpCount, 1208: sseIdeCount: serverStats?.sseIdeCount, 1209: wsIdeCount: serverStats?.wsIdeCount, 1210: ...mcpBaseUrlAnalytics(serverRef), 1211: }) 1212: return { 1213: name, 1214: client, 1215: type: 'connected' as const, 1216: capabilities: capabilities ?? {}, 1217: serverInfo: serverVersion, 1218: instructions, 1219: config: serverRef, 1220: cleanup: wrappedCleanup, 1221: } 1222: } catch (error) { 1223: const connectionDurationMs = Date.now() - connectStartTime 1224: logEvent('tengu_mcp_server_connection_failed', { 1225: connectionDurationMs, 1226: totalServers: serverStats?.totalServers || 1, 1227: stdioCount: 1228: serverStats?.stdioCount || (serverRef.type === 'stdio' ? 1 : 0), 1229: sseCount: serverStats?.sseCount || (serverRef.type === 'sse' ? 1 : 0), 1230: httpCount: 1231: serverStats?.httpCount || (serverRef.type === 'http' ? 1 : 0), 1232: sseIdeCount: 1233: serverStats?.sseIdeCount || (serverRef.type === 'sse-ide' ? 1 : 0), 1234: wsIdeCount: 1235: serverStats?.wsIdeCount || (serverRef.type === 'ws-ide' ? 1 : 0), 1236: transportType: (serverRef.type ?? 1237: 'stdio') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1238: ...mcpBaseUrlAnalytics(serverRef), 1239: }) 1240: logMCPDebug( 1241: name, 1242: `Connection failed after ${connectionDurationMs}ms: ${errorMessage(error)}`, 1243: ) 1244: logMCPError(name, `Connection failed: ${errorMessage(error)}`) 1245: if (inProcessServer) { 1246: inProcessServer.close().catch(() => {}) 1247: } 1248: return { 1249: name, 1250: type: 'failed' as const, 1251: config: serverRef, 1252: error: errorMessage(error), 1253: } 1254: } 1255: }, 1256: getServerCacheKey, 1257: ) 1258: export async function clearServerCache( 1259: name: string, 1260: serverRef: ScopedMcpServerConfig, 1261: ): Promise<void> { 1262: const key = getServerCacheKey(name, serverRef) 1263: try { 1264: const wrappedClient = await connectToServer(name, serverRef) 1265: if (wrappedClient.type === 'connected') { 1266: await wrappedClient.cleanup() 1267: } 1268: } catch { 1269: } 1270: connectToServer.cache.delete(key) 1271: fetchToolsForClient.cache.delete(name) 1272: fetchResourcesForClient.cache.delete(name) 1273: fetchCommandsForClient.cache.delete(name) 1274: if (feature('MCP_SKILLS')) { 1275: fetchMcpSkillsForClient!.cache.delete(name) 1276: } 1277: } 1278: export async function ensureConnectedClient( 1279: client: ConnectedMCPServer, 1280: ): Promise<ConnectedMCPServer> { 1281: if (client.config.type === 'sdk') { 1282: return client 1283: } 1284: const connectedClient = await connectToServer(client.name, client.config) 1285: if (connectedClient.type !== 'connected') { 1286: throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 1287: `MCP server "${client.name}" is not connected`, 1288: 'MCP server not connected', 1289: ) 1290: } 1291: return connectedClient 1292: } 1293: export function areMcpConfigsEqual( 1294: a: ScopedMcpServerConfig, 1295: b: ScopedMcpServerConfig, 1296: ): boolean { 1297: if (a.type !== b.type) return false 1298: const { scope: _scopeA, ...configA } = a 1299: const { scope: _scopeB, ...configB } = b 1300: return jsonStringify(configA) === jsonStringify(configB) 1301: } 1302: const MCP_FETCH_CACHE_SIZE = 20 1303: export function mcpToolInputToAutoClassifierInput( 1304: input: Record<string, unknown>, 1305: toolName: string, 1306: ): string { 1307: const keys = Object.keys(input) 1308: return keys.length > 0 1309: ? keys.map(k => `${k}=${String(input[k])}`).join(' ') 1310: : toolName 1311: } 1312: export const fetchToolsForClient = memoizeWithLRU( 1313: async (client: MCPServerConnection): Promise<Tool[]> => { 1314: if (client.type !== 'connected') return [] 1315: try { 1316: if (!client.capabilities?.tools) { 1317: return [] 1318: } 1319: const result = (await client.client.request( 1320: { method: 'tools/list' }, 1321: ListToolsResultSchema, 1322: )) as ListToolsResult 1323: const toolsToProcess = recursivelySanitizeUnicode(result.tools) 1324: const skipPrefix = 1325: client.config.type === 'sdk' && 1326: isEnvTruthy(process.env.CLAUDE_AGENT_SDK_MCP_NO_PREFIX) 1327: return toolsToProcess 1328: .map((tool): Tool => { 1329: const fullyQualifiedName = buildMcpToolName(client.name, tool.name) 1330: return { 1331: ...MCPTool, 1332: name: skipPrefix ? tool.name : fullyQualifiedName, 1333: mcpInfo: { serverName: client.name, toolName: tool.name }, 1334: isMcp: true, 1335: searchHint: 1336: typeof tool._meta?.['anthropic/searchHint'] === 'string' 1337: ? tool._meta['anthropic/searchHint'] 1338: .replace(/\s+/g, ' ') 1339: .trim() || undefined 1340: : undefined, 1341: alwaysLoad: tool._meta?.['anthropic/alwaysLoad'] === true, 1342: async description() { 1343: return tool.description ?? '' 1344: }, 1345: async prompt() { 1346: const desc = tool.description ?? '' 1347: return desc.length > MAX_MCP_DESCRIPTION_LENGTH 1348: ? desc.slice(0, MAX_MCP_DESCRIPTION_LENGTH) + '… [truncated]' 1349: : desc 1350: }, 1351: isConcurrencySafe() { 1352: return tool.annotations?.readOnlyHint ?? false 1353: }, 1354: isReadOnly() { 1355: return tool.annotations?.readOnlyHint ?? false 1356: }, 1357: toAutoClassifierInput(input) { 1358: return mcpToolInputToAutoClassifierInput(input, tool.name) 1359: }, 1360: isDestructive() { 1361: return tool.annotations?.destructiveHint ?? false 1362: }, 1363: isOpenWorld() { 1364: return tool.annotations?.openWorldHint ?? false 1365: }, 1366: isSearchOrReadCommand() { 1367: return classifyMcpToolForCollapse(client.name, tool.name) 1368: }, 1369: inputJSONSchema: tool.inputSchema as Tool['inputJSONSchema'], 1370: async checkPermissions() { 1371: return { 1372: behavior: 'passthrough' as const, 1373: message: 'MCPTool requires permission.', 1374: suggestions: [ 1375: { 1376: type: 'addRules' as const, 1377: rules: [ 1378: { 1379: toolName: fullyQualifiedName, 1380: ruleContent: undefined, 1381: }, 1382: ], 1383: behavior: 'allow' as const, 1384: destination: 'localSettings' as const, 1385: }, 1386: ], 1387: } 1388: }, 1389: async call( 1390: args: Record<string, unknown>, 1391: context, 1392: _canUseTool, 1393: parentMessage, 1394: onProgress?: ToolCallProgress<MCPProgress>, 1395: ) { 1396: const toolUseId = extractToolUseId(parentMessage) 1397: const meta = toolUseId 1398: ? { 'claudecode/toolUseId': toolUseId } 1399: : {} 1400: if (onProgress && toolUseId) { 1401: onProgress({ 1402: toolUseID: toolUseId, 1403: data: { 1404: type: 'mcp_progress', 1405: status: 'started', 1406: serverName: client.name, 1407: toolName: tool.name, 1408: }, 1409: }) 1410: } 1411: const startTime = Date.now() 1412: const MAX_SESSION_RETRIES = 1 1413: for (let attempt = 0; ; attempt++) { 1414: try { 1415: const connectedClient = await ensureConnectedClient(client) 1416: const mcpResult = await callMCPToolWithUrlElicitationRetry({ 1417: client: connectedClient, 1418: clientConnection: client, 1419: tool: tool.name, 1420: args, 1421: meta, 1422: signal: context.abortController.signal, 1423: setAppState: context.setAppState, 1424: onProgress: 1425: onProgress && toolUseId 1426: ? progressData => { 1427: onProgress({ 1428: toolUseID: toolUseId, 1429: data: progressData, 1430: }) 1431: } 1432: : undefined, 1433: handleElicitation: context.handleElicitation, 1434: }) 1435: if (onProgress && toolUseId) { 1436: onProgress({ 1437: toolUseID: toolUseId, 1438: data: { 1439: type: 'mcp_progress', 1440: status: 'completed', 1441: serverName: client.name, 1442: toolName: tool.name, 1443: elapsedTimeMs: Date.now() - startTime, 1444: }, 1445: }) 1446: } 1447: return { 1448: data: mcpResult.content, 1449: ...((mcpResult._meta || mcpResult.structuredContent) && { 1450: mcpMeta: { 1451: ...(mcpResult._meta && { 1452: _meta: mcpResult._meta, 1453: }), 1454: ...(mcpResult.structuredContent && { 1455: structuredContent: mcpResult.structuredContent, 1456: }), 1457: }, 1458: }), 1459: } 1460: } catch (error) { 1461: if ( 1462: error instanceof McpSessionExpiredError && 1463: attempt < MAX_SESSION_RETRIES 1464: ) { 1465: logMCPDebug( 1466: client.name, 1467: `Retrying tool '${tool.name}' after session recovery`, 1468: ) 1469: continue 1470: } 1471: if (onProgress && toolUseId) { 1472: onProgress({ 1473: toolUseID: toolUseId, 1474: data: { 1475: type: 'mcp_progress', 1476: status: 'failed', 1477: serverName: client.name, 1478: toolName: tool.name, 1479: elapsedTimeMs: Date.now() - startTime, 1480: }, 1481: }) 1482: } 1483: if ( 1484: error instanceof Error && 1485: !( 1486: error instanceof 1487: TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 1488: ) 1489: ) { 1490: const name = error.constructor.name 1491: if (name === 'Error') { 1492: throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 1493: error.message, 1494: error.message.slice(0, 200), 1495: ) 1496: } 1497: if ( 1498: name === 'McpError' && 1499: 'code' in error && 1500: typeof error.code === 'number' 1501: ) { 1502: throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 1503: error.message, 1504: `McpError ${error.code}`, 1505: ) 1506: } 1507: } 1508: throw error 1509: } 1510: } 1511: }, 1512: userFacingName() { 1513: const displayName = tool.annotations?.title || tool.name 1514: return `${client.name} - ${displayName} (MCP)` 1515: }, 1516: ...(isClaudeInChromeMCPServer(client.name) && 1517: (client.config.type === 'stdio' || !client.config.type) 1518: ? claudeInChromeToolRendering().getClaudeInChromeMCPToolOverrides( 1519: tool.name, 1520: ) 1521: : {}), 1522: ...(feature('CHICAGO_MCP') && 1523: (client.config.type === 'stdio' || !client.config.type) && 1524: isComputerUseMCPServer!(client.name) 1525: ? computerUseWrapper!().getComputerUseMCPToolOverrides(tool.name) 1526: : {}), 1527: } 1528: }) 1529: .filter(isIncludedMcpTool) 1530: } catch (error) { 1531: logMCPError(client.name, `Failed to fetch tools: ${errorMessage(error)}`) 1532: return [] 1533: } 1534: }, 1535: (client: MCPServerConnection) => client.name, 1536: MCP_FETCH_CACHE_SIZE, 1537: ) 1538: export const fetchResourcesForClient = memoizeWithLRU( 1539: async (client: MCPServerConnection): Promise<ServerResource[]> => { 1540: if (client.type !== 'connected') return [] 1541: try { 1542: if (!client.capabilities?.resources) { 1543: return [] 1544: } 1545: const result = await client.client.request( 1546: { method: 'resources/list' }, 1547: ListResourcesResultSchema, 1548: ) 1549: if (!result.resources) return [] 1550: return result.resources.map(resource => ({ 1551: ...resource, 1552: server: client.name, 1553: })) 1554: } catch (error) { 1555: logMCPError( 1556: client.name, 1557: `Failed to fetch resources: ${errorMessage(error)}`, 1558: ) 1559: return [] 1560: } 1561: }, 1562: (client: MCPServerConnection) => client.name, 1563: MCP_FETCH_CACHE_SIZE, 1564: ) 1565: export const fetchCommandsForClient = memoizeWithLRU( 1566: async (client: MCPServerConnection): Promise<Command[]> => { 1567: if (client.type !== 'connected') return [] 1568: try { 1569: if (!client.capabilities?.prompts) { 1570: return [] 1571: } 1572: const result = (await client.client.request( 1573: { method: 'prompts/list' }, 1574: ListPromptsResultSchema, 1575: )) as ListPromptsResult 1576: if (!result.prompts) return [] 1577: const promptsToProcess = recursivelySanitizeUnicode(result.prompts) 1578: return promptsToProcess.map(prompt => { 1579: const argNames = Object.values(prompt.arguments ?? {}).map(k => k.name) 1580: return { 1581: type: 'prompt' as const, 1582: name: 'mcp__' + normalizeNameForMCP(client.name) + '__' + prompt.name, 1583: description: prompt.description ?? '', 1584: hasUserSpecifiedDescription: !!prompt.description, 1585: contentLength: 0, // Dynamic MCP content 1586: isEnabled: () => true, 1587: isHidden: false, 1588: isMcp: true, 1589: progressMessage: 'running', 1590: userFacingName() { 1591: return `${client.name}:${prompt.name} (MCP)` 1592: }, 1593: argNames, 1594: source: 'mcp', 1595: async getPromptForCommand(args: string) { 1596: const argsArray = args.split(' ') 1597: try { 1598: const connectedClient = await ensureConnectedClient(client) 1599: const result = await connectedClient.client.getPrompt({ 1600: name: prompt.name, 1601: arguments: zipObject(argNames, argsArray), 1602: }) 1603: const transformed = await Promise.all( 1604: result.messages.map(message => 1605: transformResultContent(message.content, connectedClient.name), 1606: ), 1607: ) 1608: return transformed.flat() 1609: } catch (error) { 1610: logMCPError( 1611: client.name, 1612: `Error running command '${prompt.name}': ${errorMessage(error)}`, 1613: ) 1614: throw error 1615: } 1616: }, 1617: } 1618: }) 1619: } catch (error) { 1620: logMCPError( 1621: client.name, 1622: `Failed to fetch commands: ${errorMessage(error)}`, 1623: ) 1624: return [] 1625: } 1626: }, 1627: (client: MCPServerConnection) => client.name, 1628: MCP_FETCH_CACHE_SIZE, 1629: ) 1630: export async function callIdeRpc( 1631: toolName: string, 1632: args: Record<string, unknown>, 1633: client: ConnectedMCPServer, 1634: ): Promise<string | ContentBlockParam[] | undefined> { 1635: const result = await callMCPTool({ 1636: client, 1637: tool: toolName, 1638: args, 1639: signal: createAbortController().signal, 1640: }) 1641: return result.content 1642: } 1643: export async function reconnectMcpServerImpl( 1644: name: string, 1645: config: ScopedMcpServerConfig, 1646: ): Promise<{ 1647: client: MCPServerConnection 1648: tools: Tool[] 1649: commands: Command[] 1650: resources?: ServerResource[] 1651: }> { 1652: try { 1653: clearKeychainCache() 1654: await clearServerCache(name, config) 1655: const client = await connectToServer(name, config) 1656: if (client.type !== 'connected') { 1657: return { 1658: client, 1659: tools: [], 1660: commands: [], 1661: } 1662: } 1663: if (config.type === 'claudeai-proxy') { 1664: markClaudeAiMcpConnected(name) 1665: } 1666: const supportsResources = !!client.capabilities?.resources 1667: const [tools, mcpCommands, mcpSkills, resources] = await Promise.all([ 1668: fetchToolsForClient(client), 1669: fetchCommandsForClient(client), 1670: feature('MCP_SKILLS') && supportsResources 1671: ? fetchMcpSkillsForClient!(client) 1672: : Promise.resolve([]), 1673: supportsResources ? fetchResourcesForClient(client) : Promise.resolve([]), 1674: ]) 1675: const commands = [...mcpCommands, ...mcpSkills] 1676: const resourceTools: Tool[] = [] 1677: if (supportsResources) { 1678: const hasResourceTools = [ListMcpResourcesTool, ReadMcpResourceTool].some( 1679: tool => tools.some(t => toolMatchesName(t, tool.name)), 1680: ) 1681: if (!hasResourceTools) { 1682: resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool) 1683: } 1684: } 1685: return { 1686: client, 1687: tools: [...tools, ...resourceTools], 1688: commands, 1689: resources: resources.length > 0 ? resources : undefined, 1690: } 1691: } catch (error) { 1692: logMCPError(name, `Error during reconnection: ${errorMessage(error)}`) 1693: return { 1694: client: { name, type: 'failed' as const, config }, 1695: tools: [], 1696: commands: [], 1697: } 1698: } 1699: } 1700: async function processBatched<T>( 1701: items: T[], 1702: concurrency: number, 1703: processor: (item: T) => Promise<void>, 1704: ): Promise<void> { 1705: await pMap(items, processor, { concurrency }) 1706: } 1707: export async function getMcpToolsCommandsAndResources( 1708: onConnectionAttempt: (params: { 1709: client: MCPServerConnection 1710: tools: Tool[] 1711: commands: Command[] 1712: resources?: ServerResource[] 1713: }) => void, 1714: mcpConfigs?: Record<string, ScopedMcpServerConfig>, 1715: ): Promise<void> { 1716: let resourceToolsAdded = false 1717: const allConfigEntries = Object.entries( 1718: mcpConfigs ?? (await getAllMcpConfigs()).servers, 1719: ) 1720: const configEntries: typeof allConfigEntries = [] 1721: for (const entry of allConfigEntries) { 1722: if (isMcpServerDisabled(entry[0])) { 1723: onConnectionAttempt({ 1724: client: { name: entry[0], type: 'disabled', config: entry[1] }, 1725: tools: [], 1726: commands: [], 1727: }) 1728: } else { 1729: configEntries.push(entry) 1730: } 1731: } 1732: const totalServers = configEntries.length 1733: const stdioCount = count(configEntries, ([_, c]) => c.type === 'stdio') 1734: const sseCount = count(configEntries, ([_, c]) => c.type === 'sse') 1735: const httpCount = count(configEntries, ([_, c]) => c.type === 'http') 1736: const sseIdeCount = count(configEntries, ([_, c]) => c.type === 'sse-ide') 1737: const wsIdeCount = count(configEntries, ([_, c]) => c.type === 'ws-ide') 1738: const localServers = configEntries.filter(([_, config]) => 1739: isLocalMcpServer(config), 1740: ) 1741: const remoteServers = configEntries.filter( 1742: ([_, config]) => !isLocalMcpServer(config), 1743: ) 1744: const serverStats = { 1745: totalServers, 1746: stdioCount, 1747: sseCount, 1748: httpCount, 1749: sseIdeCount, 1750: wsIdeCount, 1751: } 1752: const processServer = async ([name, config]: [ 1753: string, 1754: ScopedMcpServerConfig, 1755: ]): Promise<void> => { 1756: try { 1757: if (isMcpServerDisabled(name)) { 1758: onConnectionAttempt({ 1759: client: { 1760: name, 1761: type: 'disabled', 1762: config, 1763: }, 1764: tools: [], 1765: commands: [], 1766: }) 1767: return 1768: } 1769: if ( 1770: (config.type === 'claudeai-proxy' || 1771: config.type === 'http' || 1772: config.type === 'sse') && 1773: ((await isMcpAuthCached(name)) || 1774: ((config.type === 'http' || config.type === 'sse') && 1775: hasMcpDiscoveryButNoToken(name, config))) 1776: ) { 1777: logMCPDebug(name, `Skipping connection (cached needs-auth)`) 1778: onConnectionAttempt({ 1779: client: { name, type: 'needs-auth' as const, config }, 1780: tools: [createMcpAuthTool(name, config)], 1781: commands: [], 1782: }) 1783: return 1784: } 1785: const client = await connectToServer(name, config, serverStats) 1786: if (client.type !== 'connected') { 1787: onConnectionAttempt({ 1788: client, 1789: tools: 1790: client.type === 'needs-auth' 1791: ? [createMcpAuthTool(name, config)] 1792: : [], 1793: commands: [], 1794: }) 1795: return 1796: } 1797: if (config.type === 'claudeai-proxy') { 1798: markClaudeAiMcpConnected(name) 1799: } 1800: const supportsResources = !!client.capabilities?.resources 1801: const [tools, mcpCommands, mcpSkills, resources] = await Promise.all([ 1802: fetchToolsForClient(client), 1803: fetchCommandsForClient(client), 1804: feature('MCP_SKILLS') && supportsResources 1805: ? fetchMcpSkillsForClient!(client) 1806: : Promise.resolve([]), 1807: supportsResources 1808: ? fetchResourcesForClient(client) 1809: : Promise.resolve([]), 1810: ]) 1811: const commands = [...mcpCommands, ...mcpSkills] 1812: const resourceTools: Tool[] = [] 1813: if (supportsResources && !resourceToolsAdded) { 1814: resourceToolsAdded = true 1815: resourceTools.push(ListMcpResourcesTool, ReadMcpResourceTool) 1816: } 1817: onConnectionAttempt({ 1818: client, 1819: tools: [...tools, ...resourceTools], 1820: commands, 1821: resources: resources.length > 0 ? resources : undefined, 1822: }) 1823: } catch (error) { 1824: logMCPError( 1825: name, 1826: `Error fetching tools/commands/resources: ${errorMessage(error)}`, 1827: ) 1828: onConnectionAttempt({ 1829: client: { name, type: 'failed' as const, config }, 1830: tools: [], 1831: commands: [], 1832: }) 1833: } 1834: } 1835: await Promise.all([ 1836: processBatched( 1837: localServers, 1838: getMcpServerConnectionBatchSize(), 1839: processServer, 1840: ), 1841: processBatched( 1842: remoteServers, 1843: getRemoteMcpServerConnectionBatchSize(), 1844: processServer, 1845: ), 1846: ]) 1847: } 1848: export function prefetchAllMcpResources( 1849: mcpConfigs: Record<string, ScopedMcpServerConfig>, 1850: ): Promise<{ 1851: clients: MCPServerConnection[] 1852: tools: Tool[] 1853: commands: Command[] 1854: }> { 1855: return new Promise(resolve => { 1856: let pendingCount = 0 1857: let completedCount = 0 1858: pendingCount = Object.keys(mcpConfigs).length 1859: if (pendingCount === 0) { 1860: void resolve({ 1861: clients: [], 1862: tools: [], 1863: commands: [], 1864: }) 1865: return 1866: } 1867: const clients: MCPServerConnection[] = [] 1868: const tools: Tool[] = [] 1869: const commands: Command[] = [] 1870: getMcpToolsCommandsAndResources(result => { 1871: clients.push(result.client) 1872: tools.push(...result.tools) 1873: commands.push(...result.commands) 1874: completedCount++ 1875: if (completedCount >= pendingCount) { 1876: const commandsMetadataLength = commands.reduce((sum, command) => { 1877: const commandMetadataLength = 1878: command.name.length + 1879: (command.description ?? '').length + 1880: (command.argumentHint ?? '').length 1881: return sum + commandMetadataLength 1882: }, 0) 1883: logEvent('tengu_mcp_tools_commands_loaded', { 1884: tools_count: tools.length, 1885: commands_count: commands.length, 1886: commands_metadata_length: commandsMetadataLength, 1887: }) 1888: void resolve({ 1889: clients, 1890: tools, 1891: commands, 1892: }) 1893: } 1894: }, mcpConfigs).catch(error => { 1895: logMCPError( 1896: 'prefetchAllMcpResources', 1897: `Failed to get MCP resources: ${errorMessage(error)}`, 1898: ) 1899: void resolve({ 1900: clients: [], 1901: tools: [], 1902: commands: [], 1903: }) 1904: }) 1905: }) 1906: } 1907: export async function transformResultContent( 1908: resultContent: PromptMessage['content'], 1909: serverName: string, 1910: ): Promise<Array<ContentBlockParam>> { 1911: switch (resultContent.type) { 1912: case 'text': 1913: return [ 1914: { 1915: type: 'text', 1916: text: resultContent.text, 1917: }, 1918: ] 1919: case 'audio': { 1920: const audioData = resultContent as { 1921: type: 'audio' 1922: data: string 1923: mimeType?: string 1924: } 1925: return await persistBlobToTextBlock( 1926: Buffer.from(audioData.data, 'base64'), 1927: audioData.mimeType, 1928: serverName, 1929: `[Audio from ${serverName}] `, 1930: ) 1931: } 1932: case 'image': { 1933: const imageBuffer = Buffer.from(String(resultContent.data), 'base64') 1934: const ext = resultContent.mimeType?.split('/')[1] || 'png' 1935: const resized = await maybeResizeAndDownsampleImageBuffer( 1936: imageBuffer, 1937: imageBuffer.length, 1938: ext, 1939: ) 1940: return [ 1941: { 1942: type: 'image', 1943: source: { 1944: data: resized.buffer.toString('base64'), 1945: media_type: 1946: `image/${resized.mediaType}` as Base64ImageSource['media_type'], 1947: type: 'base64', 1948: }, 1949: }, 1950: ] 1951: } 1952: case 'resource': { 1953: const resource = resultContent.resource 1954: const prefix = `[Resource from ${serverName} at ${resource.uri}] ` 1955: if ('text' in resource) { 1956: return [ 1957: { 1958: type: 'text', 1959: text: `${prefix}${resource.text}`, 1960: }, 1961: ] 1962: } else if ('blob' in resource) { 1963: const isImage = IMAGE_MIME_TYPES.has(resource.mimeType ?? '') 1964: if (isImage) { 1965: // Resize and compress image blob, enforcing API dimension limits 1966: const imageBuffer = Buffer.from(resource.blob, 'base64') 1967: const ext = resource.mimeType?.split('/')[1] || 'png' 1968: const resized = await maybeResizeAndDownsampleImageBuffer( 1969: imageBuffer, 1970: imageBuffer.length, 1971: ext, 1972: ) 1973: const content: MessageParam['content'] = [] 1974: if (prefix) { 1975: content.push({ 1976: type: 'text', 1977: text: prefix, 1978: }) 1979: } 1980: content.push({ 1981: type: 'image', 1982: source: { 1983: data: resized.buffer.toString('base64'), 1984: media_type: 1985: `image/${resized.mediaType}` as Base64ImageSource['media_type'], 1986: type: 'base64', 1987: }, 1988: }) 1989: return content 1990: } else { 1991: return await persistBlobToTextBlock( 1992: Buffer.from(resource.blob, 'base64'), 1993: resource.mimeType, 1994: serverName, 1995: prefix, 1996: ) 1997: } 1998: } 1999: return [] 2000: } 2001: case 'resource_link': { 2002: const resourceLink = resultContent as ResourceLink 2003: let text = `[Resource link: ${resourceLink.name}] ${resourceLink.uri}` 2004: if (resourceLink.description) { 2005: text += ` (${resourceLink.description})` 2006: } 2007: return [ 2008: { 2009: type: 'text', 2010: text, 2011: }, 2012: ] 2013: } 2014: default: 2015: return [] 2016: } 2017: } 2018: async function persistBlobToTextBlock( 2019: bytes: Buffer, 2020: mimeType: string | undefined, 2021: serverName: string, 2022: sourceDescription: string, 2023: ): Promise<Array<ContentBlockParam>> { 2024: const persistId = `mcp-${normalizeNameForMCP(serverName)}-blob-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` 2025: const result = await persistBinaryContent(bytes, mimeType, persistId) 2026: if ('error' in result) { 2027: return [ 2028: { 2029: type: 'text', 2030: text: `${sourceDescription}Binary content (${mimeType || 'unknown type'}, ${bytes.length} bytes) could not be saved to disk: ${result.error}`, 2031: }, 2032: ] 2033: } 2034: return [ 2035: { 2036: type: 'text', 2037: text: getBinaryBlobSavedMessage( 2038: result.filepath, 2039: mimeType, 2040: result.size, 2041: sourceDescription, 2042: ), 2043: }, 2044: ] 2045: } 2046: export type MCPResultType = 'toolResult' | 'structuredContent' | 'contentArray' 2047: export type TransformedMCPResult = { 2048: content: MCPToolResult 2049: type: MCPResultType 2050: schema?: string 2051: } 2052: export function inferCompactSchema(value: unknown, depth = 2): string { 2053: if (value === null) return 'null' 2054: if (Array.isArray(value)) { 2055: if (value.length === 0) return '[]' 2056: return `[${inferCompactSchema(value[0], depth - 1)}]` 2057: } 2058: if (typeof value === 'object') { 2059: if (depth <= 0) return '{...}' 2060: const entries = Object.entries(value).slice(0, 10) 2061: const props = entries.map( 2062: ([k, v]) => `${k}: ${inferCompactSchema(v, depth - 1)}`, 2063: ) 2064: const suffix = Object.keys(value).length > 10 ? ', ...' : '' 2065: return `{${props.join(', ')}${suffix}}` 2066: } 2067: return typeof value 2068: } 2069: export async function transformMCPResult( 2070: result: unknown, 2071: tool: string, // Tool name for validation (e.g., "search") 2072: name: string, // Server name for transformation (e.g., "slack") 2073: ): Promise<TransformedMCPResult> { 2074: if (result && typeof result === 'object') { 2075: if ('toolResult' in result) { 2076: return { 2077: content: String(result.toolResult), 2078: type: 'toolResult', 2079: } 2080: } 2081: if ( 2082: 'structuredContent' in result && 2083: result.structuredContent !== undefined 2084: ) { 2085: return { 2086: content: jsonStringify(result.structuredContent), 2087: type: 'structuredContent', 2088: schema: inferCompactSchema(result.structuredContent), 2089: } 2090: } 2091: if ('content' in result && Array.isArray(result.content)) { 2092: const transformedContent = ( 2093: await Promise.all( 2094: result.content.map(item => transformResultContent(item, name)), 2095: ) 2096: ).flat() 2097: return { 2098: content: transformedContent, 2099: type: 'contentArray', 2100: schema: inferCompactSchema(transformedContent), 2101: } 2102: } 2103: } 2104: const errorMessage = `MCP server "${name}" tool "${tool}": unexpected response format` 2105: logMCPError(name, errorMessage) 2106: throw new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 2107: errorMessage, 2108: 'MCP tool unexpected response format', 2109: ) 2110: } 2111: function contentContainsImages(content: MCPToolResult): boolean { 2112: if (!content || typeof content === 'string') { 2113: return false 2114: } 2115: return content.some(block => block.type === 'image') 2116: } 2117: export async function processMCPResult( 2118: result: unknown, 2119: tool: string, 2120: name: string, 2121: ): Promise<MCPToolResult> { 2122: const { content, type, schema } = await transformMCPResult(result, tool, name) 2123: if (name === 'ide') { 2124: return content 2125: } 2126: if (!(await mcpContentNeedsTruncation(content))) { 2127: return content 2128: } 2129: const sizeEstimateTokens = getContentSizeEstimate(content) 2130: if (isEnvDefinedFalsy(process.env.ENABLE_MCP_LARGE_OUTPUT_FILES)) { 2131: logEvent('tengu_mcp_large_result_handled', { 2132: outcome: 'truncated', 2133: reason: 'env_disabled', 2134: sizeEstimateTokens, 2135: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2136: return await truncateMcpContentIfNeeded(content) 2137: } 2138: if (!content) { 2139: return content 2140: } 2141: if (contentContainsImages(content)) { 2142: logEvent('tengu_mcp_large_result_handled', { 2143: outcome: 'truncated', 2144: reason: 'contains_images', 2145: sizeEstimateTokens, 2146: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2147: return await truncateMcpContentIfNeeded(content) 2148: } 2149: const timestamp = Date.now() 2150: const persistId = `mcp-${normalizeNameForMCP(name)}-${normalizeNameForMCP(tool)}-${timestamp}` 2151: const contentStr = 2152: typeof content === 'string' ? content : jsonStringify(content, null, 2) 2153: const persistResult = await persistToolResult(contentStr, persistId) 2154: if (isPersistError(persistResult)) { 2155: const contentLength = contentStr.length 2156: logEvent('tengu_mcp_large_result_handled', { 2157: outcome: 'truncated', 2158: reason: 'persist_failed', 2159: sizeEstimateTokens, 2160: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2161: return `Error: result (${contentLength.toLocaleString()} characters) exceeds maximum allowed tokens. Failed to save output to file: ${persistResult.error}. If this MCP server provides pagination or filtering tools, use them to retrieve specific portions of the data.` 2162: } 2163: logEvent('tengu_mcp_large_result_handled', { 2164: outcome: 'persisted', 2165: reason: 'file_saved', 2166: sizeEstimateTokens, 2167: persistedSizeChars: persistResult.originalSize, 2168: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2169: const formatDescription = getFormatDescription(type, schema) 2170: return getLargeOutputInstructions( 2171: persistResult.filepath, 2172: persistResult.originalSize, 2173: formatDescription, 2174: ) 2175: } 2176: type MCPToolCallResult = { 2177: content: MCPToolResult 2178: _meta?: Record<string, unknown> 2179: structuredContent?: Record<string, unknown> 2180: } 2181: export async function callMCPToolWithUrlElicitationRetry({ 2182: client: connectedClient, 2183: clientConnection, 2184: tool, 2185: args, 2186: meta, 2187: signal, 2188: setAppState, 2189: onProgress, 2190: callToolFn = callMCPTool, 2191: handleElicitation, 2192: }: { 2193: client: ConnectedMCPServer 2194: clientConnection: MCPServerConnection 2195: tool: string 2196: args: Record<string, unknown> 2197: meta?: Record<string, unknown> 2198: signal: AbortSignal 2199: setAppState: (f: (prev: AppState) => AppState) => void 2200: onProgress?: (data: MCPProgress) => void 2201: callToolFn?: (opts: { 2202: client: ConnectedMCPServer 2203: tool: string 2204: args: Record<string, unknown> 2205: meta?: Record<string, unknown> 2206: signal: AbortSignal 2207: onProgress?: (data: MCPProgress) => void 2208: }) => Promise<MCPToolCallResult> 2209: handleElicitation?: ( 2210: serverName: string, 2211: params: ElicitRequestURLParams, 2212: signal: AbortSignal, 2213: ) => Promise<ElicitResult> 2214: }): Promise<MCPToolCallResult> { 2215: const MAX_URL_ELICITATION_RETRIES = 3 2216: for (let attempt = 0; ; attempt++) { 2217: try { 2218: return await callToolFn({ 2219: client: connectedClient, 2220: tool, 2221: args, 2222: meta, 2223: signal, 2224: onProgress, 2225: }) 2226: } catch (error) { 2227: if ( 2228: !(error instanceof McpError) || 2229: error.code !== ErrorCode.UrlElicitationRequired 2230: ) { 2231: throw error 2232: } 2233: if (attempt >= MAX_URL_ELICITATION_RETRIES) { 2234: throw error 2235: } 2236: const errorData = error.data 2237: const rawElicitations = 2238: errorData != null && 2239: typeof errorData === 'object' && 2240: 'elicitations' in errorData && 2241: Array.isArray(errorData.elicitations) 2242: ? (errorData.elicitations as unknown[]) 2243: : [] 2244: const elicitations = rawElicitations.filter( 2245: (e): e is ElicitRequestURLParams => { 2246: if (e == null || typeof e !== 'object') return false 2247: const obj = e as Record<string, unknown> 2248: return ( 2249: obj.mode === 'url' && 2250: typeof obj.url === 'string' && 2251: typeof obj.elicitationId === 'string' && 2252: typeof obj.message === 'string' 2253: ) 2254: }, 2255: ) 2256: const serverName = 2257: clientConnection.type === 'connected' 2258: ? clientConnection.name 2259: : 'unknown' 2260: if (elicitations.length === 0) { 2261: logMCPDebug( 2262: serverName, 2263: `Tool '${tool}' returned -32042 but no valid elicitations in error data`, 2264: ) 2265: throw error 2266: } 2267: logMCPDebug( 2268: serverName, 2269: `Tool '${tool}' requires URL elicitation (error -32042, attempt ${attempt + 1}), processing ${elicitations.length} elicitation(s)`, 2270: ) 2271: for (const elicitation of elicitations) { 2272: const { elicitationId } = elicitation 2273: const hookResponse = await runElicitationHooks( 2274: serverName, 2275: elicitation, 2276: signal, 2277: ) 2278: if (hookResponse) { 2279: logMCPDebug( 2280: serverName, 2281: `URL elicitation ${elicitationId} resolved by hook: ${jsonStringify(hookResponse)}`, 2282: ) 2283: if (hookResponse.action !== 'accept') { 2284: return { 2285: content: `URL elicitation was ${hookResponse.action === 'decline' ? 'declined' : hookResponse.action + 'ed'} by a hook. The tool "${tool}" could not complete because it requires the user to open a URL.`, 2286: } 2287: } 2288: continue 2289: } 2290: let userResult: ElicitResult 2291: if (handleElicitation) { 2292: userResult = await handleElicitation(serverName, elicitation, signal) 2293: } else { 2294: const waitingState: ElicitationWaitingState = { 2295: actionLabel: 'Retry now', 2296: showCancel: true, 2297: } 2298: userResult = await new Promise<ElicitResult>(resolve => { 2299: const onAbort = () => { 2300: void resolve({ action: 'cancel' }) 2301: } 2302: if (signal.aborted) { 2303: onAbort() 2304: return 2305: } 2306: signal.addEventListener('abort', onAbort, { once: true }) 2307: setAppState(prev => ({ 2308: ...prev, 2309: elicitation: { 2310: queue: [ 2311: ...prev.elicitation.queue, 2312: { 2313: serverName, 2314: requestId: `error-elicit-${elicitationId}`, 2315: params: elicitation, 2316: signal, 2317: waitingState, 2318: respond: result => { 2319: if (result.action === 'accept') { 2320: return 2321: } 2322: signal.removeEventListener('abort', onAbort) 2323: void resolve(result) 2324: }, 2325: onWaitingDismiss: action => { 2326: signal.removeEventListener('abort', onAbort) 2327: if (action === 'retry') { 2328: void resolve({ action: 'accept' }) 2329: } else { 2330: void resolve({ action: 'cancel' }) 2331: } 2332: }, 2333: }, 2334: ], 2335: }, 2336: })) 2337: }) 2338: } 2339: const finalResult = await runElicitationResultHooks( 2340: serverName, 2341: userResult, 2342: signal, 2343: 'url', 2344: elicitationId, 2345: ) 2346: if (finalResult.action !== 'accept') { 2347: logMCPDebug( 2348: serverName, 2349: `User ${finalResult.action === 'decline' ? 'declined' : finalResult.action + 'ed'} URL elicitation ${elicitationId}`, 2350: ) 2351: return { 2352: content: `URL elicitation was ${finalResult.action === 'decline' ? 'declined' : finalResult.action + 'ed'} by the user. The tool "${tool}" could not complete because it requires the user to open a URL.`, 2353: } 2354: } 2355: logMCPDebug( 2356: serverName, 2357: `Elicitation ${elicitationId} completed, retrying tool call`, 2358: ) 2359: } 2360: } 2361: } 2362: } 2363: async function callMCPTool({ 2364: client: { client, name, config }, 2365: tool, 2366: args, 2367: meta, 2368: signal, 2369: onProgress, 2370: }: { 2371: client: ConnectedMCPServer 2372: tool: string 2373: args: Record<string, unknown> 2374: meta?: Record<string, unknown> 2375: signal: AbortSignal 2376: onProgress?: (data: MCPProgress) => void 2377: }): Promise<{ 2378: content: MCPToolResult 2379: _meta?: Record<string, unknown> 2380: structuredContent?: Record<string, unknown> 2381: }> { 2382: const toolStartTime = Date.now() 2383: let progressInterval: NodeJS.Timeout | undefined 2384: try { 2385: logMCPDebug(name, `Calling MCP tool: ${tool}`) 2386: progressInterval = setInterval( 2387: (startTime, name, tool) => { 2388: const elapsed = Date.now() - startTime 2389: const elapsedSeconds = Math.floor(elapsed / 1000) 2390: const duration = `${elapsedSeconds}s` 2391: logMCPDebug(name, `Tool '${tool}' still running (${duration} elapsed)`) 2392: }, 2393: 30000, 2394: toolStartTime, 2395: name, 2396: tool, 2397: ) 2398: const timeoutMs = getMcpToolTimeoutMs() 2399: let timeoutId: NodeJS.Timeout | undefined 2400: const timeoutPromise = new Promise<never>((_, reject) => { 2401: timeoutId = setTimeout( 2402: (reject, name, tool, timeoutMs) => { 2403: reject( 2404: new TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 2405: `MCP server "${name}" tool "${tool}" timed out after ${Math.floor(timeoutMs / 1000)}s`, 2406: 'MCP tool timeout', 2407: ), 2408: ) 2409: }, 2410: timeoutMs, 2411: reject, 2412: name, 2413: tool, 2414: timeoutMs, 2415: ) 2416: }) 2417: const result = await Promise.race([ 2418: client.callTool( 2419: { 2420: name: tool, 2421: arguments: args, 2422: _meta: meta, 2423: }, 2424: CallToolResultSchema, 2425: { 2426: signal, 2427: timeout: timeoutMs, 2428: onprogress: onProgress 2429: ? sdkProgress => { 2430: onProgress({ 2431: type: 'mcp_progress', 2432: status: 'progress', 2433: serverName: name, 2434: toolName: tool, 2435: progress: sdkProgress.progress, 2436: total: sdkProgress.total, 2437: progressMessage: sdkProgress.message, 2438: }) 2439: } 2440: : undefined, 2441: }, 2442: ), 2443: timeoutPromise, 2444: ]).finally(() => { 2445: if (timeoutId) { 2446: clearTimeout(timeoutId) 2447: } 2448: }) 2449: if ('isError' in result && result.isError) { 2450: let errorDetails = 'Unknown error' 2451: if ( 2452: 'content' in result && 2453: Array.isArray(result.content) && 2454: result.content.length > 0 2455: ) { 2456: const firstContent = result.content[0] 2457: if ( 2458: firstContent && 2459: typeof firstContent === 'object' && 2460: 'text' in firstContent 2461: ) { 2462: errorDetails = firstContent.text 2463: } 2464: } else if ('error' in result) { 2465: errorDetails = String(result.error) 2466: } 2467: logMCPError(name, errorDetails) 2468: throw new McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS( 2469: errorDetails, 2470: 'MCP tool returned error', 2471: '_meta' in result && result._meta ? { _meta: result._meta } : undefined, 2472: ) 2473: } 2474: const elapsed = Date.now() - toolStartTime 2475: const duration = 2476: elapsed < 1000 2477: ? `${elapsed}ms` 2478: : elapsed < 60000 2479: ? `${Math.floor(elapsed / 1000)}s` 2480: : `${Math.floor(elapsed / 60000)}m ${Math.floor((elapsed % 60000) / 1000)}s` 2481: logMCPDebug(name, `Tool '${tool}' completed successfully in ${duration}`) 2482: const codeIndexingTool = detectCodeIndexingFromMcpServerName(name) 2483: if (codeIndexingTool) { 2484: logEvent('tengu_code_indexing_tool_used', { 2485: tool: codeIndexingTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2486: source: 2487: 'mcp' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2488: success: true, 2489: }) 2490: } 2491: const content = await processMCPResult(result, tool, name) 2492: return { 2493: content, 2494: _meta: result._meta as Record<string, unknown> | undefined, 2495: structuredContent: result.structuredContent as 2496: | Record<string, unknown> 2497: | undefined, 2498: } 2499: } catch (e) { 2500: if (progressInterval !== undefined) { 2501: clearInterval(progressInterval) 2502: } 2503: const elapsed = Date.now() - toolStartTime 2504: if (e instanceof Error && e.name !== 'AbortError') { 2505: logMCPDebug( 2506: name, 2507: `Tool '${tool}' failed after ${Math.floor(elapsed / 1000)}s: ${e.message}`, 2508: ) 2509: } 2510: if (e instanceof Error) { 2511: const errorCode = 'code' in e ? (e.code as number | undefined) : undefined 2512: if (errorCode === 401 || e instanceof UnauthorizedError) { 2513: logMCPDebug( 2514: name, 2515: `Tool call returned 401 Unauthorized - token may have expired`, 2516: ) 2517: logEvent('tengu_mcp_tool_call_auth_error', {}) 2518: throw new McpAuthError( 2519: name, 2520: `MCP server "${name}" requires re-authorization (token expired)`, 2521: ) 2522: } 2523: const isSessionExpired = isMcpSessionExpiredError(e) 2524: const isConnectionClosedOnHttp = 2525: 'code' in e && 2526: (e as Error & { code?: number }).code === -32000 && 2527: e.message.includes('Connection closed') && 2528: (config.type === 'http' || config.type === 'claudeai-proxy') 2529: if (isSessionExpired || isConnectionClosedOnHttp) { 2530: logMCPDebug( 2531: name, 2532: `MCP session expired during tool call (${isSessionExpired ? '404/-32001' : 'connection closed'}), clearing connection cache for re-initialization`, 2533: ) 2534: logEvent('tengu_mcp_session_expired', {}) 2535: await clearServerCache(name, config) 2536: throw new McpSessionExpiredError(name) 2537: } 2538: } 2539: if (!(e instanceof Error) || e.name !== 'AbortError') { 2540: throw e 2541: } 2542: return { content: undefined } 2543: } finally { 2544: if (progressInterval !== undefined) { 2545: clearInterval(progressInterval) 2546: } 2547: } 2548: } 2549: function extractToolUseId(message: AssistantMessage): string | undefined { 2550: if (message.message.content[0]?.type !== 'tool_use') { 2551: return undefined 2552: } 2553: return message.message.content[0].id 2554: } 2555: export async function setupSdkMcpClients( 2556: sdkMcpConfigs: Record<string, McpSdkServerConfig>, 2557: sendMcpMessage: ( 2558: serverName: string, 2559: message: JSONRPCMessage, 2560: ) => Promise<JSONRPCMessage>, 2561: ): Promise<{ 2562: clients: MCPServerConnection[] 2563: tools: Tool[] 2564: }> { 2565: const clients: MCPServerConnection[] = [] 2566: const tools: Tool[] = [] 2567: const results = await Promise.allSettled( 2568: Object.entries(sdkMcpConfigs).map(async ([name, config]) => { 2569: const transport = new SdkControlClientTransport(name, sendMcpMessage) 2570: const client = new Client( 2571: { 2572: name: 'claude-code', 2573: title: 'Claude Code', 2574: version: MACRO.VERSION ?? 'unknown', 2575: description: "Anthropic's agentic coding tool", 2576: websiteUrl: PRODUCT_URL, 2577: }, 2578: { 2579: capabilities: {}, 2580: }, 2581: ) 2582: try { 2583: await client.connect(transport) 2584: const capabilities = client.getServerCapabilities() 2585: const connectedClient: MCPServerConnection = { 2586: type: 'connected', 2587: name, 2588: capabilities: capabilities || {}, 2589: client, 2590: config: { ...config, scope: 'dynamic' as const }, 2591: cleanup: async () => { 2592: await client.close() 2593: }, 2594: } 2595: const serverTools: Tool[] = [] 2596: if (capabilities?.tools) { 2597: const sdkTools = await fetchToolsForClient(connectedClient) 2598: serverTools.push(...sdkTools) 2599: } 2600: return { 2601: client: connectedClient, 2602: tools: serverTools, 2603: } 2604: } catch (error) { 2605: logMCPError(name, `Failed to connect SDK MCP server: ${error}`) 2606: return { 2607: client: { 2608: type: 'failed' as const, 2609: name, 2610: config: { ...config, scope: 'user' as const }, 2611: }, 2612: tools: [], 2613: } 2614: } 2615: }), 2616: ) 2617: for (const result of results) { 2618: if (result.status === 'fulfilled') { 2619: clients.push(result.value.client) 2620: tools.push(...result.value.tools) 2621: } 2622: } 2623: return { clients, tools } 2624: }

File: src/services/mcp/config.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { chmod, open, rename, stat, unlink } from 'fs/promises' 3: import mapValues from 'lodash-es/mapValues.js' 4: import memoize from 'lodash-es/memoize.js' 5: import { dirname, join, parse } from 'path' 6: import { getPlatform } from 'src/utils/platform.js' 7: import type { PluginError } from '../../types/plugin.js' 8: import { getPluginErrorMessage } from '../../types/plugin.js' 9: import { isClaudeInChromeMCPServer } from '../../utils/claudeInChrome/common.js' 10: import { 11: getCurrentProjectConfig, 12: getGlobalConfig, 13: saveCurrentProjectConfig, 14: saveGlobalConfig, 15: } from '../../utils/config.js' 16: import { getCwd } from '../../utils/cwd.js' 17: import { logForDebugging } from '../../utils/debug.js' 18: import { getErrnoCode } from '../../utils/errors.js' 19: import { getFsImplementation } from '../../utils/fsOperations.js' 20: import { safeParseJSON } from '../../utils/json.js' 21: import { logError } from '../../utils/log.js' 22: import { getPluginMcpServers } from '../../utils/plugins/mcpPluginIntegration.js' 23: import { loadAllPluginsCacheOnly } from '../../utils/plugins/pluginLoader.js' 24: import { isSettingSourceEnabled } from '../../utils/settings/constants.js' 25: import { getManagedFilePath } from '../../utils/settings/managedPath.js' 26: import { isRestrictedToPluginOnly } from '../../utils/settings/pluginOnlyPolicy.js' 27: import { 28: getInitialSettings, 29: getSettingsForSource, 30: } from '../../utils/settings/settings.js' 31: import { 32: isMcpServerCommandEntry, 33: isMcpServerNameEntry, 34: isMcpServerUrlEntry, 35: type SettingsJson, 36: } from '../../utils/settings/types.js' 37: import type { ValidationError } from '../../utils/settings/validation.js' 38: import { jsonStringify } from '../../utils/slowOperations.js' 39: import { 40: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 41: logEvent, 42: } from '../analytics/index.js' 43: import { fetchClaudeAIMcpConfigsIfEligible } from './claudeai.js' 44: import { expandEnvVarsInString } from './envExpansion.js' 45: import { 46: type ConfigScope, 47: type McpHTTPServerConfig, 48: type McpJsonConfig, 49: McpJsonConfigSchema, 50: type McpServerConfig, 51: McpServerConfigSchema, 52: type McpSSEServerConfig, 53: type McpStdioServerConfig, 54: type McpWebSocketServerConfig, 55: type ScopedMcpServerConfig, 56: } from './types.js' 57: import { getProjectMcpServerStatus } from './utils.js' 58: export function getEnterpriseMcpFilePath(): string { 59: return join(getManagedFilePath(), 'managed-mcp.json') 60: } 61: function addScopeToServers( 62: servers: Record<string, McpServerConfig> | undefined, 63: scope: ConfigScope, 64: ): Record<string, ScopedMcpServerConfig> { 65: if (!servers) { 66: return {} 67: } 68: const scopedServers: Record<string, ScopedMcpServerConfig> = {} 69: for (const [name, config] of Object.entries(servers)) { 70: scopedServers[name] = { ...config, scope } 71: } 72: return scopedServers 73: } 74: async function writeMcpjsonFile(config: McpJsonConfig): Promise<void> { 75: const mcpJsonPath = join(getCwd(), '.mcp.json') 76: let existingMode: number | undefined 77: try { 78: const stats = await stat(mcpJsonPath) 79: existingMode = stats.mode 80: } catch (e: unknown) { 81: const code = getErrnoCode(e) 82: if (code !== 'ENOENT') { 83: throw e 84: } 85: } 86: const tempPath = `${mcpJsonPath}.tmp.${process.pid}.${Date.now()}` 87: const handle = await open(tempPath, 'w', existingMode ?? 0o644) 88: try { 89: await handle.writeFile(jsonStringify(config, null, 2), { 90: encoding: 'utf8', 91: }) 92: await handle.datasync() 93: } finally { 94: await handle.close() 95: } 96: try { 97: if (existingMode !== undefined) { 98: await chmod(tempPath, existingMode) 99: } 100: await rename(tempPath, mcpJsonPath) 101: } catch (e: unknown) { 102: try { 103: await unlink(tempPath) 104: } catch { 105: } 106: throw e 107: } 108: } 109: function getServerCommandArray(config: McpServerConfig): string[] | null { 110: if (config.type !== undefined && config.type !== 'stdio') { 111: return null 112: } 113: const stdioConfig = config as McpStdioServerConfig 114: return [stdioConfig.command, ...(stdioConfig.args ?? [])] 115: } 116: function commandArraysMatch(a: string[], b: string[]): boolean { 117: if (a.length !== b.length) { 118: return false 119: } 120: return a.every((val, idx) => val === b[idx]) 121: } 122: function getServerUrl(config: McpServerConfig): string | null { 123: return 'url' in config ? config.url : null 124: } 125: const CCR_PROXY_PATH_MARKERS = [ 126: '/v2/session_ingress/shttp/mcp/', 127: '/v2/ccr-sessions/', 128: ] 129: export function unwrapCcrProxyUrl(url: string): string { 130: if (!CCR_PROXY_PATH_MARKERS.some(m => url.includes(m))) { 131: return url 132: } 133: try { 134: const parsed = new URL(url) 135: const original = parsed.searchParams.get('mcp_url') 136: return original || url 137: } catch { 138: return url 139: } 140: } 141: export function getMcpServerSignature(config: McpServerConfig): string | null { 142: const cmd = getServerCommandArray(config) 143: if (cmd) { 144: return `stdio:${jsonStringify(cmd)}` 145: } 146: const url = getServerUrl(config) 147: if (url) { 148: return `url:${unwrapCcrProxyUrl(url)}` 149: } 150: return null 151: } 152: export function dedupPluginMcpServers( 153: pluginServers: Record<string, ScopedMcpServerConfig>, 154: manualServers: Record<string, ScopedMcpServerConfig>, 155: ): { 156: servers: Record<string, ScopedMcpServerConfig> 157: suppressed: Array<{ name: string; duplicateOf: string }> 158: } { 159: const manualSigs = new Map<string, string>() 160: for (const [name, config] of Object.entries(manualServers)) { 161: const sig = getMcpServerSignature(config) 162: if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name) 163: } 164: const servers: Record<string, ScopedMcpServerConfig> = {} 165: const suppressed: Array<{ name: string; duplicateOf: string }> = [] 166: const seenPluginSigs = new Map<string, string>() 167: for (const [name, config] of Object.entries(pluginServers)) { 168: const sig = getMcpServerSignature(config) 169: if (sig === null) { 170: servers[name] = config 171: continue 172: } 173: const manualDup = manualSigs.get(sig) 174: if (manualDup !== undefined) { 175: logForDebugging( 176: `Suppressing plugin MCP server "${name}": duplicates manually-configured "${manualDup}"`, 177: ) 178: suppressed.push({ name, duplicateOf: manualDup }) 179: continue 180: } 181: const pluginDup = seenPluginSigs.get(sig) 182: if (pluginDup !== undefined) { 183: logForDebugging( 184: `Suppressing plugin MCP server "${name}": duplicates earlier plugin server "${pluginDup}"`, 185: ) 186: suppressed.push({ name, duplicateOf: pluginDup }) 187: continue 188: } 189: seenPluginSigs.set(sig, name) 190: servers[name] = config 191: } 192: return { servers, suppressed } 193: } 194: export function dedupClaudeAiMcpServers( 195: claudeAiServers: Record<string, ScopedMcpServerConfig>, 196: manualServers: Record<string, ScopedMcpServerConfig>, 197: ): { 198: servers: Record<string, ScopedMcpServerConfig> 199: suppressed: Array<{ name: string; duplicateOf: string }> 200: } { 201: const manualSigs = new Map<string, string>() 202: for (const [name, config] of Object.entries(manualServers)) { 203: if (isMcpServerDisabled(name)) continue 204: const sig = getMcpServerSignature(config) 205: if (sig && !manualSigs.has(sig)) manualSigs.set(sig, name) 206: } 207: const servers: Record<string, ScopedMcpServerConfig> = {} 208: const suppressed: Array<{ name: string; duplicateOf: string }> = [] 209: for (const [name, config] of Object.entries(claudeAiServers)) { 210: const sig = getMcpServerSignature(config) 211: const manualDup = sig !== null ? manualSigs.get(sig) : undefined 212: if (manualDup !== undefined) { 213: logForDebugging( 214: `Suppressing claude.ai connector "${name}": duplicates manually-configured "${manualDup}"`, 215: ) 216: suppressed.push({ name, duplicateOf: manualDup }) 217: continue 218: } 219: servers[name] = config 220: } 221: return { servers, suppressed } 222: } 223: function urlPatternToRegex(pattern: string): RegExp { 224: const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&') 225: const regexStr = escaped.replace(/\*/g, '.*') 226: return new RegExp(`^${regexStr}$`) 227: } 228: function urlMatchesPattern(url: string, pattern: string): boolean { 229: const regex = urlPatternToRegex(pattern) 230: return regex.test(url) 231: } 232: function getMcpAllowlistSettings(): SettingsJson { 233: if (shouldAllowManagedMcpServersOnly()) { 234: return getSettingsForSource('policySettings') ?? {} 235: } 236: return getInitialSettings() 237: } 238: function getMcpDenylistSettings(): SettingsJson { 239: return getInitialSettings() 240: } 241: function isMcpServerDenied( 242: serverName: string, 243: config?: McpServerConfig, 244: ): boolean { 245: const settings = getMcpDenylistSettings() 246: if (!settings.deniedMcpServers) { 247: return false 248: } 249: for (const entry of settings.deniedMcpServers) { 250: if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { 251: return true 252: } 253: } 254: if (config) { 255: const serverCommand = getServerCommandArray(config) 256: if (serverCommand) { 257: for (const entry of settings.deniedMcpServers) { 258: if ( 259: isMcpServerCommandEntry(entry) && 260: commandArraysMatch(entry.serverCommand, serverCommand) 261: ) { 262: return true 263: } 264: } 265: } 266: const serverUrl = getServerUrl(config) 267: if (serverUrl) { 268: for (const entry of settings.deniedMcpServers) { 269: if ( 270: isMcpServerUrlEntry(entry) && 271: urlMatchesPattern(serverUrl, entry.serverUrl) 272: ) { 273: return true 274: } 275: } 276: } 277: } 278: return false 279: } 280: function isMcpServerAllowedByPolicy( 281: serverName: string, 282: config?: McpServerConfig, 283: ): boolean { 284: if (isMcpServerDenied(serverName, config)) { 285: return false 286: } 287: const settings = getMcpAllowlistSettings() 288: if (!settings.allowedMcpServers) { 289: return true 290: } 291: if (settings.allowedMcpServers.length === 0) { 292: return false 293: } 294: const hasCommandEntries = settings.allowedMcpServers.some( 295: isMcpServerCommandEntry, 296: ) 297: const hasUrlEntries = settings.allowedMcpServers.some(isMcpServerUrlEntry) 298: if (config) { 299: const serverCommand = getServerCommandArray(config) 300: const serverUrl = getServerUrl(config) 301: if (serverCommand) { 302: if (hasCommandEntries) { 303: for (const entry of settings.allowedMcpServers) { 304: if ( 305: isMcpServerCommandEntry(entry) && 306: commandArraysMatch(entry.serverCommand, serverCommand) 307: ) { 308: return true 309: } 310: } 311: return false 312: } else { 313: for (const entry of settings.allowedMcpServers) { 314: if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { 315: return true 316: } 317: } 318: return false 319: } 320: } else if (serverUrl) { 321: if (hasUrlEntries) { 322: for (const entry of settings.allowedMcpServers) { 323: if ( 324: isMcpServerUrlEntry(entry) && 325: urlMatchesPattern(serverUrl, entry.serverUrl) 326: ) { 327: return true 328: } 329: } 330: return false 331: } else { 332: for (const entry of settings.allowedMcpServers) { 333: if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { 334: return true 335: } 336: } 337: return false 338: } 339: } else { 340: for (const entry of settings.allowedMcpServers) { 341: if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { 342: return true 343: } 344: } 345: return false 346: } 347: } 348: for (const entry of settings.allowedMcpServers) { 349: if (isMcpServerNameEntry(entry) && entry.serverName === serverName) { 350: return true 351: } 352: } 353: return false 354: } 355: export function filterMcpServersByPolicy<T>(configs: Record<string, T>): { 356: allowed: Record<string, T> 357: blocked: string[] 358: } { 359: const allowed: Record<string, T> = {} 360: const blocked: string[] = [] 361: for (const [name, config] of Object.entries(configs)) { 362: const c = config as McpServerConfig 363: if (c.type === 'sdk' || isMcpServerAllowedByPolicy(name, c)) { 364: allowed[name] = config 365: } else { 366: blocked.push(name) 367: } 368: } 369: return { allowed, blocked } 370: } 371: function expandEnvVars(config: McpServerConfig): { 372: expanded: McpServerConfig 373: missingVars: string[] 374: } { 375: const missingVars: string[] = [] 376: function expandString(str: string): string { 377: const { expanded, missingVars: vars } = expandEnvVarsInString(str) 378: missingVars.push(...vars) 379: return expanded 380: } 381: let expanded: McpServerConfig 382: switch (config.type) { 383: case undefined: 384: case 'stdio': { 385: const stdioConfig = config as McpStdioServerConfig 386: expanded = { 387: ...stdioConfig, 388: command: expandString(stdioConfig.command), 389: args: stdioConfig.args.map(expandString), 390: env: stdioConfig.env 391: ? mapValues(stdioConfig.env, expandString) 392: : undefined, 393: } 394: break 395: } 396: case 'sse': 397: case 'http': 398: case 'ws': { 399: const remoteConfig = config as 400: | McpSSEServerConfig 401: | McpHTTPServerConfig 402: | McpWebSocketServerConfig 403: expanded = { 404: ...remoteConfig, 405: url: expandString(remoteConfig.url), 406: headers: remoteConfig.headers 407: ? mapValues(remoteConfig.headers, expandString) 408: : undefined, 409: } 410: break 411: } 412: case 'sse-ide': 413: case 'ws-ide': 414: expanded = config 415: break 416: case 'sdk': 417: expanded = config 418: break 419: case 'claudeai-proxy': 420: expanded = config 421: break 422: } 423: return { 424: expanded, 425: missingVars: [...new Set(missingVars)], 426: } 427: } 428: export async function addMcpConfig( 429: name: string, 430: config: unknown, 431: scope: ConfigScope, 432: ): Promise<void> { 433: if (name.match(/[^a-zA-Z0-9_-]/)) { 434: throw new Error( 435: `Invalid name ${name}. Names can only contain letters, numbers, hyphens, and underscores.`, 436: ) 437: } 438: if (isClaudeInChromeMCPServer(name)) { 439: throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) 440: } 441: if (feature('CHICAGO_MCP')) { 442: const { isComputerUseMCPServer } = await import( 443: '../../utils/computerUse/common.js' 444: ) 445: if (isComputerUseMCPServer(name)) { 446: throw new Error(`Cannot add MCP server "${name}": this name is reserved.`) 447: } 448: } 449: if (doesEnterpriseMcpConfigExist()) { 450: throw new Error( 451: `Cannot add MCP server: enterprise MCP configuration is active and has exclusive control over MCP servers`, 452: ) 453: } 454: const result = McpServerConfigSchema().safeParse(config) 455: if (!result.success) { 456: const formattedErrors = result.error.issues 457: .map(err => `${err.path.join('.')}: ${err.message}`) 458: .join(', ') 459: throw new Error(`Invalid configuration: ${formattedErrors}`) 460: } 461: const validatedConfig = result.data 462: if (isMcpServerDenied(name, validatedConfig)) { 463: throw new Error( 464: `Cannot add MCP server "${name}": server is explicitly blocked by enterprise policy`, 465: ) 466: } 467: if (!isMcpServerAllowedByPolicy(name, validatedConfig)) { 468: throw new Error( 469: `Cannot add MCP server "${name}": not allowed by enterprise policy`, 470: ) 471: } 472: switch (scope) { 473: case 'project': { 474: const { servers } = getProjectMcpConfigsFromCwd() 475: if (servers[name]) { 476: throw new Error(`MCP server ${name} already exists in .mcp.json`) 477: } 478: break 479: } 480: case 'user': { 481: const globalConfig = getGlobalConfig() 482: if (globalConfig.mcpServers?.[name]) { 483: throw new Error(`MCP server ${name} already exists in user config`) 484: } 485: break 486: } 487: case 'local': { 488: const projectConfig = getCurrentProjectConfig() 489: if (projectConfig.mcpServers?.[name]) { 490: throw new Error(`MCP server ${name} already exists in local config`) 491: } 492: break 493: } 494: case 'dynamic': 495: throw new Error('Cannot add MCP server to scope: dynamic') 496: case 'enterprise': 497: throw new Error('Cannot add MCP server to scope: enterprise') 498: case 'claudeai': 499: throw new Error('Cannot add MCP server to scope: claudeai') 500: } 501: switch (scope) { 502: case 'project': { 503: const { servers: existingServers } = getProjectMcpConfigsFromCwd() 504: const mcpServers: Record<string, McpServerConfig> = {} 505: for (const [serverName, serverConfig] of Object.entries( 506: existingServers, 507: )) { 508: const { scope: _, ...configWithoutScope } = serverConfig 509: mcpServers[serverName] = configWithoutScope 510: } 511: mcpServers[name] = validatedConfig 512: const mcpConfig = { mcpServers } 513: try { 514: await writeMcpjsonFile(mcpConfig) 515: } catch (error) { 516: throw new Error(`Failed to write to .mcp.json: ${error}`) 517: } 518: break 519: } 520: case 'user': { 521: saveGlobalConfig(current => ({ 522: ...current, 523: mcpServers: { 524: ...current.mcpServers, 525: [name]: validatedConfig, 526: }, 527: })) 528: break 529: } 530: case 'local': { 531: saveCurrentProjectConfig(current => ({ 532: ...current, 533: mcpServers: { 534: ...current.mcpServers, 535: [name]: validatedConfig, 536: }, 537: })) 538: break 539: } 540: default: 541: throw new Error(`Cannot add MCP server to scope: ${scope}`) 542: } 543: } 544: export async function removeMcpConfig( 545: name: string, 546: scope: ConfigScope, 547: ): Promise<void> { 548: switch (scope) { 549: case 'project': { 550: const { servers: existingServers } = getProjectMcpConfigsFromCwd() 551: if (!existingServers[name]) { 552: throw new Error(`No MCP server found with name: ${name} in .mcp.json`) 553: } 554: const mcpServers: Record<string, McpServerConfig> = {} 555: for (const [serverName, serverConfig] of Object.entries( 556: existingServers, 557: )) { 558: if (serverName !== name) { 559: const { scope: _, ...configWithoutScope } = serverConfig 560: mcpServers[serverName] = configWithoutScope 561: } 562: } 563: const mcpConfig = { mcpServers } 564: try { 565: await writeMcpjsonFile(mcpConfig) 566: } catch (error) { 567: throw new Error(`Failed to remove from .mcp.json: ${error}`) 568: } 569: break 570: } 571: case 'user': { 572: const config = getGlobalConfig() 573: if (!config.mcpServers?.[name]) { 574: throw new Error(`No user-scoped MCP server found with name: ${name}`) 575: } 576: saveGlobalConfig(current => { 577: const { [name]: _, ...restMcpServers } = current.mcpServers ?? {} 578: return { 579: ...current, 580: mcpServers: restMcpServers, 581: } 582: }) 583: break 584: } 585: case 'local': { 586: const config = getCurrentProjectConfig() 587: if (!config.mcpServers?.[name]) { 588: throw new Error(`No project-local MCP server found with name: ${name}`) 589: } 590: saveCurrentProjectConfig(current => { 591: const { [name]: _, ...restMcpServers } = current.mcpServers ?? {} 592: return { 593: ...current, 594: mcpServers: restMcpServers, 595: } 596: }) 597: break 598: } 599: default: 600: throw new Error(`Cannot remove MCP server from scope: ${scope}`) 601: } 602: } 603: export function getProjectMcpConfigsFromCwd(): { 604: servers: Record<string, ScopedMcpServerConfig> 605: errors: ValidationError[] 606: } { 607: if (!isSettingSourceEnabled('projectSettings')) { 608: return { servers: {}, errors: [] } 609: } 610: const mcpJsonPath = join(getCwd(), '.mcp.json') 611: const { config, errors } = parseMcpConfigFromFilePath({ 612: filePath: mcpJsonPath, 613: expandVars: true, 614: scope: 'project', 615: }) 616: if (!config) { 617: const nonMissingErrors = errors.filter( 618: e => !e.message.startsWith('MCP config file not found'), 619: ) 620: if (nonMissingErrors.length > 0) { 621: logForDebugging( 622: `MCP config errors for ${mcpJsonPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`, 623: { level: 'error' }, 624: ) 625: return { servers: {}, errors: nonMissingErrors } 626: } 627: return { servers: {}, errors: [] } 628: } 629: return { 630: servers: config.mcpServers 631: ? addScopeToServers(config.mcpServers, 'project') 632: : {}, 633: errors: errors || [], 634: } 635: } 636: export function getMcpConfigsByScope( 637: scope: 'project' | 'user' | 'local' | 'enterprise', 638: ): { 639: servers: Record<string, ScopedMcpServerConfig> 640: errors: ValidationError[] 641: } { 642: const sourceMap: Record< 643: string, 644: 'projectSettings' | 'userSettings' | 'localSettings' 645: > = { 646: project: 'projectSettings', 647: user: 'userSettings', 648: local: 'localSettings', 649: } 650: if (scope in sourceMap && !isSettingSourceEnabled(sourceMap[scope]!)) { 651: return { servers: {}, errors: [] } 652: } 653: switch (scope) { 654: case 'project': { 655: const allServers: Record<string, ScopedMcpServerConfig> = {} 656: const allErrors: ValidationError[] = [] 657: const dirs: string[] = [] 658: let currentDir = getCwd() 659: while (currentDir !== parse(currentDir).root) { 660: dirs.push(currentDir) 661: currentDir = dirname(currentDir) 662: } 663: for (const dir of dirs.reverse()) { 664: const mcpJsonPath = join(dir, '.mcp.json') 665: const { config, errors } = parseMcpConfigFromFilePath({ 666: filePath: mcpJsonPath, 667: expandVars: true, 668: scope: 'project', 669: }) 670: if (!config) { 671: const nonMissingErrors = errors.filter( 672: e => !e.message.startsWith('MCP config file not found'), 673: ) 674: if (nonMissingErrors.length > 0) { 675: logForDebugging( 676: `MCP config errors for ${mcpJsonPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`, 677: { level: 'error' }, 678: ) 679: allErrors.push(...nonMissingErrors) 680: } 681: continue 682: } 683: if (config.mcpServers) { 684: Object.assign(allServers, addScopeToServers(config.mcpServers, scope)) 685: } 686: if (errors.length > 0) { 687: allErrors.push(...errors) 688: } 689: } 690: return { 691: servers: allServers, 692: errors: allErrors, 693: } 694: } 695: case 'user': { 696: const mcpServers = getGlobalConfig().mcpServers 697: if (!mcpServers) { 698: return { servers: {}, errors: [] } 699: } 700: const { config, errors } = parseMcpConfig({ 701: configObject: { mcpServers }, 702: expandVars: true, 703: scope: 'user', 704: }) 705: return { 706: servers: addScopeToServers(config?.mcpServers, scope), 707: errors, 708: } 709: } 710: case 'local': { 711: const mcpServers = getCurrentProjectConfig().mcpServers 712: if (!mcpServers) { 713: return { servers: {}, errors: [] } 714: } 715: const { config, errors } = parseMcpConfig({ 716: configObject: { mcpServers }, 717: expandVars: true, 718: scope: 'local', 719: }) 720: return { 721: servers: addScopeToServers(config?.mcpServers, scope), 722: errors, 723: } 724: } 725: case 'enterprise': { 726: const enterpriseMcpPath = getEnterpriseMcpFilePath() 727: const { config, errors } = parseMcpConfigFromFilePath({ 728: filePath: enterpriseMcpPath, 729: expandVars: true, 730: scope: 'enterprise', 731: }) 732: if (!config) { 733: const nonMissingErrors = errors.filter( 734: e => !e.message.startsWith('MCP config file not found'), 735: ) 736: if (nonMissingErrors.length > 0) { 737: logForDebugging( 738: `Enterprise MCP config errors for ${enterpriseMcpPath}: ${jsonStringify(nonMissingErrors.map(e => e.message))}`, 739: { level: 'error' }, 740: ) 741: return { servers: {}, errors: nonMissingErrors } 742: } 743: return { servers: {}, errors: [] } 744: } 745: return { 746: servers: addScopeToServers(config.mcpServers, scope), 747: errors, 748: } 749: } 750: } 751: } 752: export function getMcpConfigByName(name: string): ScopedMcpServerConfig | null { 753: const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise') 754: if (isRestrictedToPluginOnly('mcp')) { 755: return enterpriseServers[name] ?? null 756: } 757: const { servers: userServers } = getMcpConfigsByScope('user') 758: const { servers: projectServers } = getMcpConfigsByScope('project') 759: const { servers: localServers } = getMcpConfigsByScope('local') 760: if (enterpriseServers[name]) { 761: return enterpriseServers[name] 762: } 763: if (localServers[name]) { 764: return localServers[name] 765: } 766: if (projectServers[name]) { 767: return projectServers[name] 768: } 769: if (userServers[name]) { 770: return userServers[name] 771: } 772: return null 773: } 774: export async function getClaudeCodeMcpConfigs( 775: dynamicServers: Record<string, ScopedMcpServerConfig> = {}, 776: extraDedupTargets: Promise< 777: Record<string, ScopedMcpServerConfig> 778: > = Promise.resolve({}), 779: ): Promise<{ 780: servers: Record<string, ScopedMcpServerConfig> 781: errors: PluginError[] 782: }> { 783: const { servers: enterpriseServers } = getMcpConfigsByScope('enterprise') 784: if (doesEnterpriseMcpConfigExist()) { 785: const filtered: Record<string, ScopedMcpServerConfig> = {} 786: for (const [name, serverConfig] of Object.entries(enterpriseServers)) { 787: if (!isMcpServerAllowedByPolicy(name, serverConfig)) { 788: continue 789: } 790: filtered[name] = serverConfig 791: } 792: return { servers: filtered, errors: [] } 793: } 794: const mcpLocked = isRestrictedToPluginOnly('mcp') 795: const noServers: { servers: Record<string, ScopedMcpServerConfig> } = { 796: servers: {}, 797: } 798: const { servers: userServers } = mcpLocked 799: ? noServers 800: : getMcpConfigsByScope('user') 801: const { servers: projectServers } = mcpLocked 802: ? noServers 803: : getMcpConfigsByScope('project') 804: const { servers: localServers } = mcpLocked 805: ? noServers 806: : getMcpConfigsByScope('local') 807: const pluginMcpServers: Record<string, ScopedMcpServerConfig> = {} 808: const pluginResult = await loadAllPluginsCacheOnly() 809: const mcpErrors: PluginError[] = [] 810: if (pluginResult.errors.length > 0) { 811: for (const error of pluginResult.errors) { 812: if ( 813: error.type === 'mcp-config-invalid' || 814: error.type === 'mcpb-download-failed' || 815: error.type === 'mcpb-extract-failed' || 816: error.type === 'mcpb-invalid-manifest' 817: ) { 818: const errorMessage = `Plugin MCP loading error - ${error.type}: ${getPluginErrorMessage(error)}` 819: logError(new Error(errorMessage)) 820: } else { 821: const errorType = error.type 822: logForDebugging( 823: `Plugin not available for MCP: ${error.source} - error type: ${errorType}`, 824: ) 825: } 826: } 827: } 828: const pluginServerResults = await Promise.all( 829: pluginResult.enabled.map(plugin => getPluginMcpServers(plugin, mcpErrors)), 830: ) 831: for (const servers of pluginServerResults) { 832: if (servers) { 833: Object.assign(pluginMcpServers, servers) 834: } 835: } 836: if (mcpErrors.length > 0) { 837: for (const error of mcpErrors) { 838: const errorMessage = `Plugin MCP server error - ${error.type}: ${getPluginErrorMessage(error)}` 839: logError(new Error(errorMessage)) 840: } 841: } 842: const approvedProjectServers: Record<string, ScopedMcpServerConfig> = {} 843: for (const [name, config] of Object.entries(projectServers)) { 844: if (getProjectMcpServerStatus(name) === 'approved') { 845: approvedProjectServers[name] = config 846: } 847: } 848: const extraTargets = await extraDedupTargets 849: const enabledManualServers: Record<string, ScopedMcpServerConfig> = {} 850: for (const [name, config] of Object.entries({ 851: ...userServers, 852: ...approvedProjectServers, 853: ...localServers, 854: ...dynamicServers, 855: ...extraTargets, 856: })) { 857: if ( 858: !isMcpServerDisabled(name) && 859: isMcpServerAllowedByPolicy(name, config) 860: ) { 861: enabledManualServers[name] = config 862: } 863: } 864: const enabledPluginServers: Record<string, ScopedMcpServerConfig> = {} 865: const disabledPluginServers: Record<string, ScopedMcpServerConfig> = {} 866: for (const [name, config] of Object.entries(pluginMcpServers)) { 867: if ( 868: isMcpServerDisabled(name) || 869: !isMcpServerAllowedByPolicy(name, config) 870: ) { 871: disabledPluginServers[name] = config 872: } else { 873: enabledPluginServers[name] = config 874: } 875: } 876: const { servers: dedupedPluginServers, suppressed } = dedupPluginMcpServers( 877: enabledPluginServers, 878: enabledManualServers, 879: ) 880: Object.assign(dedupedPluginServers, disabledPluginServers) 881: for (const { name, duplicateOf } of suppressed) { 882: const parts = name.split(':') 883: if (parts[0] !== 'plugin' || parts.length < 3) continue 884: mcpErrors.push({ 885: type: 'mcp-server-suppressed-duplicate', 886: source: name, 887: plugin: parts[1]!, 888: serverName: parts.slice(2).join(':'), 889: duplicateOf, 890: }) 891: } 892: const configs = Object.assign( 893: {}, 894: dedupedPluginServers, 895: userServers, 896: approvedProjectServers, 897: localServers, 898: ) 899: const filtered: Record<string, ScopedMcpServerConfig> = {} 900: for (const [name, serverConfig] of Object.entries(configs)) { 901: if (!isMcpServerAllowedByPolicy(name, serverConfig as McpServerConfig)) { 902: continue 903: } 904: filtered[name] = serverConfig as ScopedMcpServerConfig 905: } 906: return { servers: filtered, errors: mcpErrors } 907: } 908: export async function getAllMcpConfigs(): Promise<{ 909: servers: Record<string, ScopedMcpServerConfig> 910: errors: PluginError[] 911: }> { 912: if (doesEnterpriseMcpConfigExist()) { 913: return getClaudeCodeMcpConfigs() 914: } 915: const claudeaiPromise = fetchClaudeAIMcpConfigsIfEligible() 916: const { servers: claudeCodeServers, errors } = await getClaudeCodeMcpConfigs( 917: {}, 918: claudeaiPromise, 919: ) 920: const { allowed: claudeaiMcpServers } = filterMcpServersByPolicy( 921: await claudeaiPromise, 922: ) 923: const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers( 924: claudeaiMcpServers, 925: claudeCodeServers, 926: ) 927: const servers = Object.assign({}, dedupedClaudeAi, claudeCodeServers) 928: return { servers, errors } 929: } 930: export function parseMcpConfig(params: { 931: configObject: unknown 932: expandVars: boolean 933: scope: ConfigScope 934: filePath?: string 935: }): { 936: config: McpJsonConfig | null 937: errors: ValidationError[] 938: } { 939: const { configObject, expandVars, scope, filePath } = params 940: const schemaResult = McpJsonConfigSchema().safeParse(configObject) 941: if (!schemaResult.success) { 942: return { 943: config: null, 944: errors: schemaResult.error.issues.map(issue => ({ 945: ...(filePath && { file: filePath }), 946: path: issue.path.join('.'), 947: message: 'Does not adhere to MCP server configuration schema', 948: mcpErrorMetadata: { 949: scope, 950: severity: 'fatal', 951: }, 952: })), 953: } 954: } 955: const errors: ValidationError[] = [] 956: const validatedServers: Record<string, McpServerConfig> = {} 957: for (const [name, config] of Object.entries(schemaResult.data.mcpServers)) { 958: let configToCheck = config 959: if (expandVars) { 960: const { expanded, missingVars } = expandEnvVars(config) 961: if (missingVars.length > 0) { 962: errors.push({ 963: ...(filePath && { file: filePath }), 964: path: `mcpServers.${name}`, 965: message: `Missing environment variables: ${missingVars.join(', ')}`, 966: suggestion: `Set the following environment variables: ${missingVars.join(', ')}`, 967: mcpErrorMetadata: { 968: scope, 969: serverName: name, 970: severity: 'warning', 971: }, 972: }) 973: } 974: configToCheck = expanded 975: } 976: if ( 977: getPlatform() === 'windows' && 978: (!configToCheck.type || configToCheck.type === 'stdio') && 979: (configToCheck.command === 'npx' || 980: configToCheck.command.endsWith('\\npx') || 981: configToCheck.command.endsWith('/npx')) 982: ) { 983: errors.push({ 984: ...(filePath && { file: filePath }), 985: path: `mcpServers.${name}`, 986: message: `Windows requires 'cmd /c' wrapper to execute npx`, 987: suggestion: `Change command to "cmd" with args ["/c", "npx", ...]. See: https://code.claude.com/docs/en/mcp#configure-mcp-servers`, 988: mcpErrorMetadata: { 989: scope, 990: serverName: name, 991: severity: 'warning', 992: }, 993: }) 994: } 995: validatedServers[name] = configToCheck 996: } 997: return { 998: config: { mcpServers: validatedServers }, 999: errors, 1000: } 1001: } 1002: export function parseMcpConfigFromFilePath(params: { 1003: filePath: string 1004: expandVars: boolean 1005: scope: ConfigScope 1006: }): { 1007: config: McpJsonConfig | null 1008: errors: ValidationError[] 1009: } { 1010: const { filePath, expandVars, scope } = params 1011: const fs = getFsImplementation() 1012: let configContent: string 1013: try { 1014: configContent = fs.readFileSync(filePath, { encoding: 'utf8' }) 1015: } catch (error: unknown) { 1016: const code = getErrnoCode(error) 1017: if (code === 'ENOENT') { 1018: return { 1019: config: null, 1020: errors: [ 1021: { 1022: file: filePath, 1023: path: '', 1024: message: `MCP config file not found: ${filePath}`, 1025: suggestion: 'Check that the file path is correct', 1026: mcpErrorMetadata: { 1027: scope, 1028: severity: 'fatal', 1029: }, 1030: }, 1031: ], 1032: } 1033: } 1034: logForDebugging( 1035: `MCP config read error for ${filePath} (scope=${scope}): ${error}`, 1036: { level: 'error' }, 1037: ) 1038: return { 1039: config: null, 1040: errors: [ 1041: { 1042: file: filePath, 1043: path: '', 1044: message: `Failed to read file: ${error}`, 1045: suggestion: 'Check file permissions and ensure the file exists', 1046: mcpErrorMetadata: { 1047: scope, 1048: severity: 'fatal', 1049: }, 1050: }, 1051: ], 1052: } 1053: } 1054: const parsedJson = safeParseJSON(configContent) 1055: if (!parsedJson) { 1056: logForDebugging( 1057: `MCP config is not valid JSON: ${filePath} (scope=${scope}, length=${configContent.length}, first100=${jsonStringify(configContent.slice(0, 100))})`, 1058: { level: 'error' }, 1059: ) 1060: return { 1061: config: null, 1062: errors: [ 1063: { 1064: file: filePath, 1065: path: '', 1066: message: `MCP config is not a valid JSON`, 1067: suggestion: 'Fix the JSON syntax errors in the file', 1068: mcpErrorMetadata: { 1069: scope, 1070: severity: 'fatal', 1071: }, 1072: }, 1073: ], 1074: } 1075: } 1076: return parseMcpConfig({ 1077: configObject: parsedJson, 1078: expandVars, 1079: scope, 1080: filePath, 1081: }) 1082: } 1083: export const doesEnterpriseMcpConfigExist = memoize((): boolean => { 1084: const { config } = parseMcpConfigFromFilePath({ 1085: filePath: getEnterpriseMcpFilePath(), 1086: expandVars: true, 1087: scope: 'enterprise', 1088: }) 1089: return config !== null 1090: }) 1091: export function shouldAllowManagedMcpServersOnly(): boolean { 1092: return ( 1093: getSettingsForSource('policySettings')?.allowManagedMcpServersOnly === true 1094: ) 1095: } 1096: export function areMcpConfigsAllowedWithEnterpriseMcpConfig( 1097: configs: Record<string, ScopedMcpServerConfig>, 1098: ): boolean { 1099: return Object.values(configs).every( 1100: c => c.type === 'sdk' && c.name === 'claude-vscode', 1101: ) 1102: } 1103: const DEFAULT_DISABLED_BUILTIN = feature('CHICAGO_MCP') 1104: ? ( 1105: require('../../utils/computerUse/common.js') as typeof import('../../utils/computerUse/common.js') 1106: ).COMPUTER_USE_MCP_SERVER_NAME 1107: : null 1108: function isDefaultDisabledBuiltin(name: string): boolean { 1109: return DEFAULT_DISABLED_BUILTIN !== null && name === DEFAULT_DISABLED_BUILTIN 1110: } 1111: export function isMcpServerDisabled(name: string): boolean { 1112: const projectConfig = getCurrentProjectConfig() 1113: if (isDefaultDisabledBuiltin(name)) { 1114: const enabledServers = projectConfig.enabledMcpServers || [] 1115: return !enabledServers.includes(name) 1116: } 1117: const disabledServers = projectConfig.disabledMcpServers || [] 1118: return disabledServers.includes(name) 1119: } 1120: function toggleMembership( 1121: list: string[], 1122: name: string, 1123: shouldContain: boolean, 1124: ): string[] { 1125: const contains = list.includes(name) 1126: if (contains === shouldContain) return list 1127: return shouldContain ? [...list, name] : list.filter(s => s !== name) 1128: } 1129: export function setMcpServerEnabled(name: string, enabled: boolean): void { 1130: const isBuiltinStateChange = 1131: isDefaultDisabledBuiltin(name) && isMcpServerDisabled(name) === enabled 1132: saveCurrentProjectConfig(current => { 1133: if (isDefaultDisabledBuiltin(name)) { 1134: const prev = current.enabledMcpServers || [] 1135: const next = toggleMembership(prev, name, enabled) 1136: if (next === prev) return current 1137: return { ...current, enabledMcpServers: next } 1138: } 1139: const prev = current.disabledMcpServers || [] 1140: const next = toggleMembership(prev, name, !enabled) 1141: if (next === prev) return current 1142: return { ...current, disabledMcpServers: next } 1143: }) 1144: if (isBuiltinStateChange) { 1145: logEvent('tengu_builtin_mcp_toggle', { 1146: serverName: 1147: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1148: enabled, 1149: }) 1150: } 1151: }

File: src/services/mcp/elicitationHandler.ts

typescript 1: import type { Client } from '@modelcontextprotocol/sdk/client/index.js' 2: import { 3: ElicitationCompleteNotificationSchema, 4: type ElicitRequestParams, 5: ElicitRequestSchema, 6: type ElicitResult, 7: } from '@modelcontextprotocol/sdk/types.js' 8: import type { AppState } from '../../state/AppState.js' 9: import { 10: executeElicitationHooks, 11: executeElicitationResultHooks, 12: executeNotificationHooks, 13: } from '../../utils/hooks.js' 14: import { logMCPDebug, logMCPError } from '../../utils/log.js' 15: import { jsonStringify } from '../../utils/slowOperations.js' 16: import { 17: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 18: logEvent, 19: } from '../analytics/index.js' 20: export type ElicitationWaitingState = { 21: actionLabel: string 22: showCancel?: boolean 23: } 24: export type ElicitationRequestEvent = { 25: serverName: string 26: requestId: string | number 27: params: ElicitRequestParams 28: signal: AbortSignal 29: respond: (response: ElicitResult) => void 30: waitingState?: ElicitationWaitingState 31: onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void 32: completed?: boolean 33: } 34: function getElicitationMode(params: ElicitRequestParams): 'form' | 'url' { 35: return params.mode === 'url' ? 'url' : 'form' 36: } 37: function findElicitationInQueue( 38: queue: ElicitationRequestEvent[], 39: serverName: string, 40: elicitationId: string, 41: ): number { 42: return queue.findIndex( 43: e => 44: e.serverName === serverName && 45: e.params.mode === 'url' && 46: 'elicitationId' in e.params && 47: e.params.elicitationId === elicitationId, 48: ) 49: } 50: export function registerElicitationHandler( 51: client: Client, 52: serverName: string, 53: setAppState: (f: (prevState: AppState) => AppState) => void, 54: ): void { 55: try { 56: client.setRequestHandler(ElicitRequestSchema, async (request, extra) => { 57: logMCPDebug( 58: serverName, 59: `Received elicitation request: ${jsonStringify(request)}`, 60: ) 61: const mode = getElicitationMode(request.params) 62: logEvent('tengu_mcp_elicitation_shown', { 63: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 64: }) 65: try { 66: const hookResponse = await runElicitationHooks( 67: serverName, 68: request.params, 69: extra.signal, 70: ) 71: if (hookResponse) { 72: logMCPDebug( 73: serverName, 74: `Elicitation resolved by hook: ${jsonStringify(hookResponse)}`, 75: ) 76: logEvent('tengu_mcp_elicitation_response', { 77: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 78: action: 79: hookResponse.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 80: }) 81: return hookResponse 82: } 83: const elicitationId = 84: mode === 'url' && 'elicitationId' in request.params 85: ? (request.params.elicitationId as string | undefined) 86: : undefined 87: const response = new Promise<ElicitResult>(resolve => { 88: const onAbort = () => { 89: resolve({ action: 'cancel' }) 90: } 91: if (extra.signal.aborted) { 92: onAbort() 93: return 94: } 95: const waitingState: ElicitationWaitingState | undefined = 96: elicitationId ? { actionLabel: 'Skip confirmation' } : undefined 97: setAppState(prev => ({ 98: ...prev, 99: elicitation: { 100: queue: [ 101: ...prev.elicitation.queue, 102: { 103: serverName, 104: requestId: extra.requestId, 105: params: request.params, 106: signal: extra.signal, 107: waitingState, 108: respond: (result: ElicitResult) => { 109: extra.signal.removeEventListener('abort', onAbort) 110: logEvent('tengu_mcp_elicitation_response', { 111: mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 112: action: 113: result.action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 114: }) 115: resolve(result) 116: }, 117: }, 118: ], 119: }, 120: })) 121: extra.signal.addEventListener('abort', onAbort, { once: true }) 122: }) 123: const rawResult = await response 124: logMCPDebug( 125: serverName, 126: `Elicitation response: ${jsonStringify(rawResult)}`, 127: ) 128: const result = await runElicitationResultHooks( 129: serverName, 130: rawResult, 131: extra.signal, 132: mode, 133: elicitationId, 134: ) 135: return result 136: } catch (error) { 137: logMCPError(serverName, `Elicitation error: ${error}`) 138: return { action: 'cancel' as const } 139: } 140: }) 141: client.setNotificationHandler( 142: ElicitationCompleteNotificationSchema, 143: notification => { 144: const { elicitationId } = notification.params 145: logMCPDebug( 146: serverName, 147: `Received elicitation completion notification: ${elicitationId}`, 148: ) 149: void executeNotificationHooks({ 150: message: `MCP server "${serverName}" confirmed elicitation ${elicitationId} complete`, 151: notificationType: 'elicitation_complete', 152: }) 153: let found = false 154: setAppState(prev => { 155: const idx = findElicitationInQueue( 156: prev.elicitation.queue, 157: serverName, 158: elicitationId, 159: ) 160: if (idx === -1) return prev 161: found = true 162: const queue = [...prev.elicitation.queue] 163: queue[idx] = { ...queue[idx]!, completed: true } 164: return { ...prev, elicitation: { queue } } 165: }) 166: if (!found) { 167: logMCPDebug( 168: serverName, 169: `Ignoring completion notification for unknown elicitation: ${elicitationId}`, 170: ) 171: } 172: }, 173: ) 174: } catch { 175: return 176: } 177: } 178: export async function runElicitationHooks( 179: serverName: string, 180: params: ElicitRequestParams, 181: signal: AbortSignal, 182: ): Promise<ElicitResult | undefined> { 183: try { 184: const mode = params.mode === 'url' ? 'url' : 'form' 185: const url = 'url' in params ? (params.url as string) : undefined 186: const elicitationId = 187: 'elicitationId' in params 188: ? (params.elicitationId as string | undefined) 189: : undefined 190: const { elicitationResponse, blockingError } = 191: await executeElicitationHooks({ 192: serverName, 193: message: params.message, 194: requestedSchema: 195: 'requestedSchema' in params 196: ? (params.requestedSchema as Record<string, unknown>) 197: : undefined, 198: signal, 199: mode, 200: url, 201: elicitationId, 202: }) 203: if (blockingError) { 204: return { action: 'decline' } 205: } 206: if (elicitationResponse) { 207: return { 208: action: elicitationResponse.action, 209: content: elicitationResponse.content, 210: } 211: } 212: return undefined 213: } catch (error) { 214: logMCPError(serverName, `Elicitation hook error: ${error}`) 215: return undefined 216: } 217: } 218: export async function runElicitationResultHooks( 219: serverName: string, 220: result: ElicitResult, 221: signal: AbortSignal, 222: mode?: 'form' | 'url', 223: elicitationId?: string, 224: ): Promise<ElicitResult> { 225: try { 226: const { elicitationResultResponse, blockingError } = 227: await executeElicitationResultHooks({ 228: serverName, 229: action: result.action, 230: content: result.content as Record<string, unknown> | undefined, 231: signal, 232: mode, 233: elicitationId, 234: }) 235: if (blockingError) { 236: void executeNotificationHooks({ 237: message: `Elicitation response for server "${serverName}": decline`, 238: notificationType: 'elicitation_response', 239: }) 240: return { action: 'decline' } 241: } 242: const finalResult = elicitationResultResponse 243: ? { 244: action: elicitationResultResponse.action, 245: content: elicitationResultResponse.content ?? result.content, 246: } 247: : result 248: void executeNotificationHooks({ 249: message: `Elicitation response for server "${serverName}": ${finalResult.action}`, 250: notificationType: 'elicitation_response', 251: }) 252: return finalResult 253: } catch (error) { 254: logMCPError(serverName, `ElicitationResult hook error: ${error}`) 255: void executeNotificationHooks({ 256: message: `Elicitation response for server "${serverName}": ${result.action}`, 257: notificationType: 'elicitation_response', 258: }) 259: return result 260: } 261: }

File: src/services/mcp/envExpansion.ts

typescript 1: export function expandEnvVarsInString(value: string): { 2: expanded: string 3: missingVars: string[] 4: } { 5: const missingVars: string[] = [] 6: const expanded = value.replace(/\$\{([^}]+)\}/g, (match, varContent) => { 7: const [varName, defaultValue] = varContent.split(':-', 2) 8: const envValue = process.env[varName] 9: if (envValue !== undefined) { 10: return envValue 11: } 12: if (defaultValue !== undefined) { 13: return defaultValue 14: } 15: missingVars.push(varName) 16: return match 17: }) 18: return { 19: expanded, 20: missingVars, 21: } 22: }

File: src/services/mcp/headersHelper.ts

typescript 1: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 2: import { checkHasTrustDialogAccepted } from '../../utils/config.js' 3: import { logAntError } from '../../utils/debug.js' 4: import { errorMessage } from '../../utils/errors.js' 5: import { execFileNoThrowWithCwd } from '../../utils/execFileNoThrow.js' 6: import { logError, logMCPDebug, logMCPError } from '../../utils/log.js' 7: import { jsonParse } from '../../utils/slowOperations.js' 8: import { logEvent } from '../analytics/index.js' 9: import type { 10: McpHTTPServerConfig, 11: McpSSEServerConfig, 12: McpWebSocketServerConfig, 13: ScopedMcpServerConfig, 14: } from './types.js' 15: function isMcpServerFromProjectOrLocalSettings( 16: config: ScopedMcpServerConfig, 17: ): boolean { 18: return config.scope === 'project' || config.scope === 'local' 19: } 20: export async function getMcpHeadersFromHelper( 21: serverName: string, 22: config: McpSSEServerConfig | McpHTTPServerConfig | McpWebSocketServerConfig, 23: ): Promise<Record<string, string> | null> { 24: if (!config.headersHelper) { 25: return null 26: } 27: if ( 28: 'scope' in config && 29: isMcpServerFromProjectOrLocalSettings(config as ScopedMcpServerConfig) && 30: !getIsNonInteractiveSession() 31: ) { 32: const hasTrust = checkHasTrustDialogAccepted() 33: if (!hasTrust) { 34: const error = new Error( 35: `Security: headersHelper for MCP server '${serverName}' executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`, 36: ) 37: logAntError('MCP headersHelper invoked before trust check', error) 38: logEvent('tengu_mcp_headersHelper_missing_trust', {}) 39: return null 40: } 41: } 42: try { 43: logMCPDebug(serverName, 'Executing headersHelper to get dynamic headers') 44: const execResult = await execFileNoThrowWithCwd(config.headersHelper, [], { 45: shell: true, 46: timeout: 10000, 47: env: { 48: ...process.env, 49: CLAUDE_CODE_MCP_SERVER_NAME: serverName, 50: CLAUDE_CODE_MCP_SERVER_URL: config.url, 51: }, 52: }) 53: if (execResult.code !== 0 || !execResult.stdout) { 54: throw new Error( 55: `headersHelper for MCP server '${serverName}' did not return a valid value`, 56: ) 57: } 58: const result = execResult.stdout.trim() 59: const headers = jsonParse(result) 60: if ( 61: typeof headers !== 'object' || 62: headers === null || 63: Array.isArray(headers) 64: ) { 65: throw new Error( 66: `headersHelper for MCP server '${serverName}' must return a JSON object with string key-value pairs`, 67: ) 68: } 69: for (const [key, value] of Object.entries(headers)) { 70: if (typeof value !== 'string') { 71: throw new Error( 72: `headersHelper for MCP server '${serverName}' returned non-string value for key "${key}": ${typeof value}`, 73: ) 74: } 75: } 76: logMCPDebug( 77: serverName, 78: `Successfully retrieved ${Object.keys(headers).length} headers from headersHelper`, 79: ) 80: return headers as Record<string, string> 81: } catch (error) { 82: logMCPError( 83: serverName, 84: `Error getting headers from headersHelper: ${errorMessage(error)}`, 85: ) 86: logError( 87: new Error( 88: `Error getting MCP headers from headersHelper for server '${serverName}': ${errorMessage(error)}`, 89: ), 90: ) 91: return null 92: } 93: } 94: export async function getMcpServerHeaders( 95: serverName: string, 96: config: McpSSEServerConfig | McpHTTPServerConfig | McpWebSocketServerConfig, 97: ): Promise<Record<string, string>> { 98: const staticHeaders = config.headers || {} 99: const dynamicHeaders = 100: (await getMcpHeadersFromHelper(serverName, config)) || {} 101: return { 102: ...staticHeaders, 103: ...dynamicHeaders, 104: } 105: }

File: src/services/mcp/InProcessTransport.ts

typescript 1: import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' 2: import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' 3: class InProcessTransport implements Transport { 4: private peer: InProcessTransport | undefined 5: private closed = false 6: onclose?: () => void 7: onerror?: (error: Error) => void 8: onmessage?: (message: JSONRPCMessage) => void 9: _setPeer(peer: InProcessTransport): void { 10: this.peer = peer 11: } 12: async start(): Promise<void> {} 13: async send(message: JSONRPCMessage): Promise<void> { 14: if (this.closed) { 15: throw new Error('Transport is closed') 16: } 17: queueMicrotask(() => { 18: this.peer?.onmessage?.(message) 19: }) 20: } 21: async close(): Promise<void> { 22: if (this.closed) { 23: return 24: } 25: this.closed = true 26: this.onclose?.() 27: if (this.peer && !this.peer.closed) { 28: this.peer.closed = true 29: this.peer.onclose?.() 30: } 31: } 32: } 33: export function createLinkedTransportPair(): [Transport, Transport] { 34: const a = new InProcessTransport() 35: const b = new InProcessTransport() 36: a._setPeer(b) 37: b._setPeer(a) 38: return [a, b] 39: }

File: src/services/mcp/MCPConnectionManager.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { createContext, type ReactNode, useContext, useMemo } from 'react'; 3: import type { Command } from '../../commands.js'; 4: import type { Tool } from '../../Tool.js'; 5: import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js'; 6: import { useManageMCPConnections } from './useManageMCPConnections.js'; 7: interface MCPConnectionContextValue { 8: reconnectMcpServer: (serverName: string) => Promise<{ 9: client: MCPServerConnection; 10: tools: Tool[]; 11: commands: Command[]; 12: resources?: ServerResource[]; 13: }>; 14: toggleMcpServer: (serverName: string) => Promise<void>; 15: } 16: const MCPConnectionContext = createContext<MCPConnectionContextValue | null>(null); 17: export function useMcpReconnect() { 18: const context = useContext(MCPConnectionContext); 19: if (!context) { 20: throw new Error("useMcpReconnect must be used within MCPConnectionManager"); 21: } 22: return context.reconnectMcpServer; 23: } 24: export function useMcpToggleEnabled() { 25: const context = useContext(MCPConnectionContext); 26: if (!context) { 27: throw new Error("useMcpToggleEnabled must be used within MCPConnectionManager"); 28: } 29: return context.toggleMcpServer; 30: } 31: interface MCPConnectionManagerProps { 32: children: ReactNode; 33: dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined; 34: isStrictMcpConfig: boolean; 35: } 36: export function MCPConnectionManager(t0) { 37: const $ = _c(6); 38: const { 39: children, 40: dynamicMcpConfig, 41: isStrictMcpConfig 42: } = t0; 43: const { 44: reconnectMcpServer, 45: toggleMcpServer 46: } = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig); 47: let t1; 48: if ($[0] !== reconnectMcpServer || $[1] !== toggleMcpServer) { 49: t1 = { 50: reconnectMcpServer, 51: toggleMcpServer 52: }; 53: $[0] = reconnectMcpServer; 54: $[1] = toggleMcpServer; 55: $[2] = t1; 56: } else { 57: t1 = $[2]; 58: } 59: const value = t1; 60: let t2; 61: if ($[3] !== children || $[4] !== value) { 62: t2 = <MCPConnectionContext.Provider value={value}>{children}</MCPConnectionContext.Provider>; 63: $[3] = children; 64: $[4] = value; 65: $[5] = t2; 66: } else { 67: t2 = $[5]; 68: } 69: return t2; 70: }

File: src/services/mcp/mcpStringUtils.ts

typescript 1: import { normalizeNameForMCP } from './normalization.js' 2: export function mcpInfoFromString(toolString: string): { 3: serverName: string 4: toolName: string | undefined 5: } | null { 6: const parts = toolString.split('__') 7: const [mcpPart, serverName, ...toolNameParts] = parts 8: if (mcpPart !== 'mcp' || !serverName) { 9: return null 10: } 11: const toolName = 12: toolNameParts.length > 0 ? toolNameParts.join('__') : undefined 13: return { serverName, toolName } 14: } 15: export function getMcpPrefix(serverName: string): string { 16: return `mcp__${normalizeNameForMCP(serverName)}__` 17: } 18: export function buildMcpToolName(serverName: string, toolName: string): string { 19: return `${getMcpPrefix(serverName)}${normalizeNameForMCP(toolName)}` 20: } 21: export function getToolNameForPermissionCheck(tool: { 22: name: string 23: mcpInfo?: { serverName: string; toolName: string } 24: }): string { 25: return tool.mcpInfo 26: ? buildMcpToolName(tool.mcpInfo.serverName, tool.mcpInfo.toolName) 27: : tool.name 28: } 29: export function getMcpDisplayName( 30: fullName: string, 31: serverName: string, 32: ): string { 33: const prefix = `mcp__${normalizeNameForMCP(serverName)}__` 34: return fullName.replace(prefix, '') 35: } 36: /** 37: * Extracts just the tool/command display name from a userFacingName 38: * @param userFacingName The full user-facing name (e.g., "github - Add comment to issue (MCP)") 39: * @returns The display name without server prefix and (MCP) suffix 40: */ 41: export function extractMcpToolDisplayName(userFacingName: string): string { 42: // This is really ugly but our current Tool type doesn't make it easy to have different display names for different purposes. 43: let withoutSuffix = userFacingName.replace(/\s*\(MCP\)\s*$/, '') 44: // Trim the result 45: withoutSuffix = withoutSuffix.trim() 46: // Then, remove the server prefix (everything before " - ") 47: const dashIndex = withoutSuffix.indexOf(' - ') 48: if (dashIndex !== -1) { 49: const displayName = withoutSuffix.substring(dashIndex + 3).trim() 50: return displayName 51: } 52: return withoutSuffix 53: }

File: src/services/mcp/normalization.ts

typescript 1: const CLAUDEAI_SERVER_PREFIX = 'claude.ai ' 2: export function normalizeNameForMCP(name: string): string { 3: let normalized = name.replace(/[^a-zA-Z0-9_-]/g, '_') 4: if (name.startsWith(CLAUDEAI_SERVER_PREFIX)) { 5: normalized = normalized.replace(/_+/g, '_').replace(/^_|_$/g, '') 6: } 7: return normalized 8: }

File: src/services/mcp/oauthPort.ts

typescript 1: import { createServer } from 'http' 2: import { getPlatform } from '../../utils/platform.js' 3: const REDIRECT_PORT_RANGE = 4: getPlatform() === 'windows' 5: ? { min: 39152, max: 49151 } 6: : { min: 49152, max: 65535 } 7: const REDIRECT_PORT_FALLBACK = 3118 8: export function buildRedirectUri( 9: port: number = REDIRECT_PORT_FALLBACK, 10: ): string { 11: return `http://localhost:${port}/callback` 12: } 13: function getMcpOAuthCallbackPort(): number | undefined { 14: const port = parseInt(process.env.MCP_OAUTH_CALLBACK_PORT || '', 10) 15: return port > 0 ? port : undefined 16: } 17: /** 18: * Finds an available port in the specified range for OAuth redirect 19: * Uses random selection for better security 20: */ 21: export async function findAvailablePort(): Promise<number> { 22: // First, try the configured port if specified 23: const configuredPort = getMcpOAuthCallbackPort() 24: if (configuredPort) { 25: return configuredPort 26: } 27: const { min, max } = REDIRECT_PORT_RANGE 28: const range = max - min + 1 29: const maxAttempts = Math.min(range, 100) // Don't try forever 30: for (let attempt = 0; attempt < maxAttempts; attempt++) { 31: const port = min + Math.floor(Math.random() * range) 32: try { 33: await new Promise<void>((resolve, reject) => { 34: const testServer = createServer() 35: testServer.once('error', reject) 36: testServer.listen(port, () => { 37: testServer.close(() => resolve()) 38: }) 39: }) 40: return port 41: } catch { 42: continue 43: } 44: } 45: try { 46: await new Promise<void>((resolve, reject) => { 47: const testServer = createServer() 48: testServer.once('error', reject) 49: testServer.listen(REDIRECT_PORT_FALLBACK, () => { 50: testServer.close(() => resolve()) 51: }) 52: }) 53: return REDIRECT_PORT_FALLBACK 54: } catch { 55: throw new Error(`No available ports for OAuth redirect`) 56: } 57: }

File: src/services/mcp/officialRegistry.ts

typescript 1: import axios from 'axios' 2: import { logForDebugging } from '../../utils/debug.js' 3: import { errorMessage } from '../../utils/errors.js' 4: type RegistryServer = { 5: server: { 6: remotes?: Array<{ url: string }> 7: } 8: } 9: type RegistryResponse = { 10: servers: RegistryServer[] 11: } 12: let officialUrls: Set<string> | undefined = undefined 13: function normalizeUrl(url: string): string | undefined { 14: try { 15: const u = new URL(url) 16: u.search = '' 17: return u.toString().replace(/\/$/, '') 18: } catch { 19: return undefined 20: } 21: } 22: /** 23: * Fire-and-forget fetch of the official MCP registry. 24: * Populates officialUrls for isOfficialMcpUrl lookups. 25: */ 26: export async function prefetchOfficialMcpUrls(): Promise<void> { 27: if (process.env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC) { 28: return 29: } 30: try { 31: const response = await axios.get<RegistryResponse>( 32: 'https: 33: { timeout: 5000 }, 34: ) 35: const urls = new Set<string>() 36: for (const entry of response.data.servers) { 37: for (const remote of entry.server.remotes ?? []) { 38: const normalized = normalizeUrl(remote.url) 39: if (normalized) { 40: urls.add(normalized) 41: } 42: } 43: } 44: officialUrls = urls 45: logForDebugging(`[mcp-registry] Loaded ${urls.size} official MCP URLs`) 46: } catch (error) { 47: logForDebugging(`Failed to fetch MCP registry: ${errorMessage(error)}`, { 48: level: 'error', 49: }) 50: } 51: } 52: export function isOfficialMcpUrl(normalizedUrl: string): boolean { 53: return officialUrls?.has(normalizedUrl) ?? false 54: } 55: export function resetOfficialMcpUrlsForTesting(): void { 56: officialUrls = undefined 57: }

File: src/services/mcp/SdkControlTransport.ts

typescript 1: import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' 2: import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js' 3: export type SendMcpMessageCallback = ( 4: serverName: string, 5: message: JSONRPCMessage, 6: ) => Promise<JSONRPCMessage> 7: export class SdkControlClientTransport implements Transport { 8: private isClosed = false 9: onclose?: () => void 10: onerror?: (error: Error) => void 11: onmessage?: (message: JSONRPCMessage) => void 12: constructor( 13: private serverName: string, 14: private sendMcpMessage: SendMcpMessageCallback, 15: ) {} 16: async start(): Promise<void> {} 17: async send(message: JSONRPCMessage): Promise<void> { 18: if (this.isClosed) { 19: throw new Error('Transport is closed') 20: } 21: const response = await this.sendMcpMessage(this.serverName, message) 22: if (this.onmessage) { 23: this.onmessage(response) 24: } 25: } 26: async close(): Promise<void> { 27: if (this.isClosed) { 28: return 29: } 30: this.isClosed = true 31: this.onclose?.() 32: } 33: } 34: export class SdkControlServerTransport implements Transport { 35: private isClosed = false 36: constructor(private sendMcpMessage: (message: JSONRPCMessage) => void) {} 37: onclose?: () => void 38: onerror?: (error: Error) => void 39: onmessage?: (message: JSONRPCMessage) => void 40: async start(): Promise<void> {} 41: async send(message: JSONRPCMessage): Promise<void> { 42: if (this.isClosed) { 43: throw new Error('Transport is closed') 44: } 45: this.sendMcpMessage(message) 46: } 47: async close(): Promise<void> { 48: if (this.isClosed) { 49: return 50: } 51: this.isClosed = true 52: this.onclose?.() 53: } 54: }

File: src/services/mcp/types.ts

typescript 1: import type { Client } from '@modelcontextprotocol/sdk/client/index.js' 2: import type { 3: Resource, 4: ServerCapabilities, 5: } from '@modelcontextprotocol/sdk/types.js' 6: import { z } from 'zod/v4' 7: import { lazySchema } from '../../utils/lazySchema.js' 8: export const ConfigScopeSchema = lazySchema(() => 9: z.enum([ 10: 'local', 11: 'user', 12: 'project', 13: 'dynamic', 14: 'enterprise', 15: 'claudeai', 16: 'managed', 17: ]), 18: ) 19: export type ConfigScope = z.infer<ReturnType<typeof ConfigScopeSchema>> 20: export const TransportSchema = lazySchema(() => 21: z.enum(['stdio', 'sse', 'sse-ide', 'http', 'ws', 'sdk']), 22: ) 23: export type Transport = z.infer<ReturnType<typeof TransportSchema>> 24: export const McpStdioServerConfigSchema = lazySchema(() => 25: z.object({ 26: type: z.literal('stdio').optional(), 27: command: z.string().min(1, 'Command cannot be empty'), 28: args: z.array(z.string()).default([]), 29: env: z.record(z.string(), z.string()).optional(), 30: }), 31: ) 32: const McpXaaConfigSchema = lazySchema(() => z.boolean()) 33: const McpOAuthConfigSchema = lazySchema(() => 34: z.object({ 35: clientId: z.string().optional(), 36: callbackPort: z.number().int().positive().optional(), 37: authServerMetadataUrl: z 38: .string() 39: .url() 40: .startsWith('https://', { 41: message: 'authServerMetadataUrl must use https://', 42: }) 43: .optional(), 44: xaa: McpXaaConfigSchema().optional(), 45: }), 46: ) 47: export const McpSSEServerConfigSchema = lazySchema(() => 48: z.object({ 49: type: z.literal('sse'), 50: url: z.string(), 51: headers: z.record(z.string(), z.string()).optional(), 52: headersHelper: z.string().optional(), 53: oauth: McpOAuthConfigSchema().optional(), 54: }), 55: ) 56: export const McpSSEIDEServerConfigSchema = lazySchema(() => 57: z.object({ 58: type: z.literal('sse-ide'), 59: url: z.string(), 60: ideName: z.string(), 61: ideRunningInWindows: z.boolean().optional(), 62: }), 63: ) 64: export const McpWebSocketIDEServerConfigSchema = lazySchema(() => 65: z.object({ 66: type: z.literal('ws-ide'), 67: url: z.string(), 68: ideName: z.string(), 69: authToken: z.string().optional(), 70: ideRunningInWindows: z.boolean().optional(), 71: }), 72: ) 73: export const McpHTTPServerConfigSchema = lazySchema(() => 74: z.object({ 75: type: z.literal('http'), 76: url: z.string(), 77: headers: z.record(z.string(), z.string()).optional(), 78: headersHelper: z.string().optional(), 79: oauth: McpOAuthConfigSchema().optional(), 80: }), 81: ) 82: export const McpWebSocketServerConfigSchema = lazySchema(() => 83: z.object({ 84: type: z.literal('ws'), 85: url: z.string(), 86: headers: z.record(z.string(), z.string()).optional(), 87: headersHelper: z.string().optional(), 88: }), 89: ) 90: export const McpSdkServerConfigSchema = lazySchema(() => 91: z.object({ 92: type: z.literal('sdk'), 93: name: z.string(), 94: }), 95: ) 96: export const McpClaudeAIProxyServerConfigSchema = lazySchema(() => 97: z.object({ 98: type: z.literal('claudeai-proxy'), 99: url: z.string(), 100: id: z.string(), 101: }), 102: ) 103: export const McpServerConfigSchema = lazySchema(() => 104: z.union([ 105: McpStdioServerConfigSchema(), 106: McpSSEServerConfigSchema(), 107: McpSSEIDEServerConfigSchema(), 108: McpWebSocketIDEServerConfigSchema(), 109: McpHTTPServerConfigSchema(), 110: McpWebSocketServerConfigSchema(), 111: McpSdkServerConfigSchema(), 112: McpClaudeAIProxyServerConfigSchema(), 113: ]), 114: ) 115: export type McpStdioServerConfig = z.infer< 116: ReturnType<typeof McpStdioServerConfigSchema> 117: > 118: export type McpSSEServerConfig = z.infer< 119: ReturnType<typeof McpSSEServerConfigSchema> 120: > 121: export type McpSSEIDEServerConfig = z.infer< 122: ReturnType<typeof McpSSEIDEServerConfigSchema> 123: > 124: export type McpWebSocketIDEServerConfig = z.infer< 125: ReturnType<typeof McpWebSocketIDEServerConfigSchema> 126: > 127: export type McpHTTPServerConfig = z.infer< 128: ReturnType<typeof McpHTTPServerConfigSchema> 129: > 130: export type McpWebSocketServerConfig = z.infer< 131: ReturnType<typeof McpWebSocketServerConfigSchema> 132: > 133: export type McpSdkServerConfig = z.infer< 134: ReturnType<typeof McpSdkServerConfigSchema> 135: > 136: export type McpClaudeAIProxyServerConfig = z.infer< 137: ReturnType<typeof McpClaudeAIProxyServerConfigSchema> 138: > 139: export type McpServerConfig = z.infer<ReturnType<typeof McpServerConfigSchema>> 140: export type ScopedMcpServerConfig = McpServerConfig & { 141: scope: ConfigScope 142: pluginSource?: string 143: } 144: export const McpJsonConfigSchema = lazySchema(() => 145: z.object({ 146: mcpServers: z.record(z.string(), McpServerConfigSchema()), 147: }), 148: ) 149: export type McpJsonConfig = z.infer<ReturnType<typeof McpJsonConfigSchema>> 150: export type ConnectedMCPServer = { 151: client: Client 152: name: string 153: type: 'connected' 154: capabilities: ServerCapabilities 155: serverInfo?: { 156: name: string 157: version: string 158: } 159: instructions?: string 160: config: ScopedMcpServerConfig 161: cleanup: () => Promise<void> 162: } 163: export type FailedMCPServer = { 164: name: string 165: type: 'failed' 166: config: ScopedMcpServerConfig 167: error?: string 168: } 169: export type NeedsAuthMCPServer = { 170: name: string 171: type: 'needs-auth' 172: config: ScopedMcpServerConfig 173: } 174: export type PendingMCPServer = { 175: name: string 176: type: 'pending' 177: config: ScopedMcpServerConfig 178: reconnectAttempt?: number 179: maxReconnectAttempts?: number 180: } 181: export type DisabledMCPServer = { 182: name: string 183: type: 'disabled' 184: config: ScopedMcpServerConfig 185: } 186: export type MCPServerConnection = 187: | ConnectedMCPServer 188: | FailedMCPServer 189: | NeedsAuthMCPServer 190: | PendingMCPServer 191: | DisabledMCPServer 192: export type ServerResource = Resource & { server: string } 193: export interface SerializedTool { 194: name: string 195: description: string 196: inputJSONSchema?: { 197: [x: string]: unknown 198: type: 'object' 199: properties?: { 200: [x: string]: unknown 201: } 202: } 203: isMcp?: boolean 204: originalToolName?: string 205: } 206: export interface SerializedClient { 207: name: string 208: type: 'connected' | 'failed' | 'needs-auth' | 'pending' | 'disabled' 209: capabilities?: ServerCapabilities 210: } 211: export interface MCPCliState { 212: clients: SerializedClient[] 213: configs: Record<string, ScopedMcpServerConfig> 214: tools: SerializedTool[] 215: resources: Record<string, ServerResource[]> 216: normalizedNames?: Record<string, string> 217: }

File: src/services/mcp/useManageMCPConnections.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { basename } from 'path' 3: import { useCallback, useEffect, useRef } from 'react' 4: import { getSessionId } from '../../bootstrap/state.js' 5: import type { Command } from '../../commands.js' 6: import type { Tool } from '../../Tool.js' 7: import { 8: clearServerCache, 9: fetchCommandsForClient, 10: fetchResourcesForClient, 11: fetchToolsForClient, 12: getMcpToolsCommandsAndResources, 13: reconnectMcpServerImpl, 14: } from './client.js' 15: import type { 16: MCPServerConnection, 17: ScopedMcpServerConfig, 18: ServerResource, 19: } from './types.js' 20: const fetchMcpSkillsForClient = feature('MCP_SKILLS') 21: ? ( 22: require('../../skills/mcpSkills.js') as typeof import('../../skills/mcpSkills.js') 23: ).fetchMcpSkillsForClient 24: : null 25: const clearSkillIndexCache = feature('EXPERIMENTAL_SKILL_SEARCH') 26: ? ( 27: require('../skillSearch/localSearch.js') as typeof import('../skillSearch/localSearch.js') 28: ).clearSkillIndexCache 29: : null 30: import { 31: PromptListChangedNotificationSchema, 32: ResourceListChangedNotificationSchema, 33: ToolListChangedNotificationSchema, 34: } from '@modelcontextprotocol/sdk/types.js' 35: import omit from 'lodash-es/omit.js' 36: import reject from 'lodash-es/reject.js' 37: import { 38: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 39: logEvent, 40: } from 'src/services/analytics/index.js' 41: import { 42: dedupClaudeAiMcpServers, 43: doesEnterpriseMcpConfigExist, 44: filterMcpServersByPolicy, 45: getClaudeCodeMcpConfigs, 46: isMcpServerDisabled, 47: setMcpServerEnabled, 48: } from 'src/services/mcp/config.js' 49: import type { AppState } from 'src/state/AppState.js' 50: import type { PluginError } from 'src/types/plugin.js' 51: import { logForDebugging } from 'src/utils/debug.js' 52: import { getAllowedChannels } from '../../bootstrap/state.js' 53: import { useNotifications } from '../../context/notifications.js' 54: import { 55: useAppState, 56: useAppStateStore, 57: useSetAppState, 58: } from '../../state/AppState.js' 59: import { errorMessage } from '../../utils/errors.js' 60: import { logMCPDebug, logMCPError } from '../../utils/log.js' 61: import { enqueue } from '../../utils/messageQueueManager.js' 62: import { 63: CHANNEL_PERMISSION_METHOD, 64: ChannelMessageNotificationSchema, 65: ChannelPermissionNotificationSchema, 66: findChannelEntry, 67: gateChannelServer, 68: wrapChannelMessage, 69: } from './channelNotification.js' 70: import { 71: type ChannelPermissionCallbacks, 72: createChannelPermissionCallbacks, 73: isChannelPermissionRelayEnabled, 74: } from './channelPermissions.js' 75: import { 76: clearClaudeAIMcpConfigsCache, 77: fetchClaudeAIMcpConfigsIfEligible, 78: } from './claudeai.js' 79: import { registerElicitationHandler } from './elicitationHandler.js' 80: import { getMcpPrefix } from './mcpStringUtils.js' 81: import { commandBelongsToServer, excludeStalePluginClients } from './utils.js' 82: const MAX_RECONNECT_ATTEMPTS = 5 83: const INITIAL_BACKOFF_MS = 1000 84: const MAX_BACKOFF_MS = 30000 85: function getErrorKey(error: PluginError): string { 86: const plugin = 'plugin' in error ? error.plugin : 'no-plugin' 87: return `${error.type}:${error.source}:${plugin}` 88: } 89: function addErrorsToAppState( 90: setAppState: (updater: (prev: AppState) => AppState) => void, 91: newErrors: PluginError[], 92: ): void { 93: if (newErrors.length === 0) return 94: setAppState(prevState => { 95: const existingKeys = new Set( 96: prevState.plugins.errors.map(e => getErrorKey(e)), 97: ) 98: const uniqueNewErrors = newErrors.filter( 99: error => !existingKeys.has(getErrorKey(error)), 100: ) 101: if (uniqueNewErrors.length === 0) { 102: return prevState 103: } 104: return { 105: ...prevState, 106: plugins: { 107: ...prevState.plugins, 108: errors: [...prevState.plugins.errors, ...uniqueNewErrors], 109: }, 110: } 111: }) 112: } 113: export function useManageMCPConnections( 114: dynamicMcpConfig: Record<string, ScopedMcpServerConfig> | undefined, 115: isStrictMcpConfig = false, 116: ) { 117: const store = useAppStateStore() 118: const _authVersion = useAppState(s => s.authVersion) 119: const _pluginReconnectKey = useAppState(s => s.mcp.pluginReconnectKey) 120: const setAppState = useSetAppState() 121: const reconnectTimersRef = useRef<Map<string, NodeJS.Timeout>>(new Map()) 122: const channelWarnedKindsRef = useRef< 123: Set<'disabled' | 'auth' | 'policy' | 'marketplace' | 'allowlist'> 124: >(new Set()) 125: const channelPermCallbacksRef = useRef<ChannelPermissionCallbacks | null>( 126: null, 127: ) 128: if ( 129: (feature('KAIROS') || feature('KAIROS_CHANNELS')) && 130: channelPermCallbacksRef.current === null 131: ) { 132: channelPermCallbacksRef.current = createChannelPermissionCallbacks() 133: } 134: useEffect(() => { 135: if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { 136: const callbacks = channelPermCallbacksRef.current 137: if (!callbacks) return 138: if (!isChannelPermissionRelayEnabled()) return 139: setAppState(prev => { 140: if (prev.channelPermissionCallbacks === callbacks) return prev 141: return { ...prev, channelPermissionCallbacks: callbacks } 142: }) 143: return () => { 144: setAppState(prev => { 145: if (prev.channelPermissionCallbacks === undefined) return prev 146: return { ...prev, channelPermissionCallbacks: undefined } 147: }) 148: } 149: } 150: }, [setAppState]) 151: const { addNotification } = useNotifications() 152: const MCP_BATCH_FLUSH_MS = 16 153: type PendingUpdate = MCPServerConnection & { 154: tools?: Tool[] 155: commands?: Command[] 156: resources?: ServerResource[] 157: } 158: const pendingUpdatesRef = useRef<PendingUpdate[]>([]) 159: const flushTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) 160: const flushPendingUpdates = useCallback(() => { 161: flushTimerRef.current = null 162: const updates = pendingUpdatesRef.current 163: if (updates.length === 0) return 164: pendingUpdatesRef.current = [] 165: setAppState(prevState => { 166: let mcp = prevState.mcp 167: for (const update of updates) { 168: const { 169: tools: rawTools, 170: commands: rawCmds, 171: resources: rawRes, 172: ...client 173: } = update 174: const tools = 175: client.type === 'disabled' || client.type === 'failed' 176: ? (rawTools ?? []) 177: : rawTools 178: const commands = 179: client.type === 'disabled' || client.type === 'failed' 180: ? (rawCmds ?? []) 181: : rawCmds 182: const resources = 183: client.type === 'disabled' || client.type === 'failed' 184: ? (rawRes ?? []) 185: : rawRes 186: const prefix = getMcpPrefix(client.name) 187: const existingClientIndex = mcp.clients.findIndex( 188: c => c.name === client.name, 189: ) 190: const updatedClients = 191: existingClientIndex === -1 192: ? [...mcp.clients, client] 193: : mcp.clients.map(c => (c.name === client.name ? client : c)) 194: const updatedTools = 195: tools === undefined 196: ? mcp.tools 197: : [...reject(mcp.tools, t => t.name?.startsWith(prefix)), ...tools] 198: const updatedCommands = 199: commands === undefined 200: ? mcp.commands 201: : [ 202: ...reject(mcp.commands, c => 203: commandBelongsToServer(c, client.name), 204: ), 205: ...commands, 206: ] 207: const updatedResources = 208: resources === undefined 209: ? mcp.resources 210: : { 211: ...mcp.resources, 212: ...(resources.length > 0 213: ? { [client.name]: resources } 214: : omit(mcp.resources, client.name)), 215: } 216: mcp = { 217: ...mcp, 218: clients: updatedClients, 219: tools: updatedTools, 220: commands: updatedCommands, 221: resources: updatedResources, 222: } 223: } 224: return { ...prevState, mcp } 225: }) 226: }, [setAppState]) 227: const updateServer = useCallback( 228: (update: PendingUpdate) => { 229: pendingUpdatesRef.current.push(update) 230: if (flushTimerRef.current === null) { 231: flushTimerRef.current = setTimeout( 232: flushPendingUpdates, 233: MCP_BATCH_FLUSH_MS, 234: ) 235: } 236: }, 237: [flushPendingUpdates], 238: ) 239: const onConnectionAttempt = useCallback( 240: ({ 241: client, 242: tools, 243: commands, 244: resources, 245: }: { 246: client: MCPServerConnection 247: tools: Tool[] 248: commands: Command[] 249: resources?: ServerResource[] 250: }) => { 251: updateServer({ ...client, tools, commands, resources }) 252: switch (client.type) { 253: case 'connected': { 254: registerElicitationHandler(client.client, client.name, setAppState) 255: client.client.onclose = () => { 256: const configType = client.config.type ?? 'stdio' 257: clearServerCache(client.name, client.config).catch(() => { 258: logForDebugging( 259: `Failed to invalidate the server cache: ${client.name}`, 260: ) 261: }) 262: if (isMcpServerDisabled(client.name)) { 263: logMCPDebug( 264: client.name, 265: `Server is disabled, skipping automatic reconnection`, 266: ) 267: return 268: } 269: if (configType !== 'stdio' && configType !== 'sdk') { 270: const transportType = getTransportDisplayName(configType) 271: logMCPDebug( 272: client.name, 273: `${transportType} transport closed/disconnected, attempting automatic reconnection`, 274: ) 275: const existingTimer = reconnectTimersRef.current.get(client.name) 276: if (existingTimer) { 277: clearTimeout(existingTimer) 278: reconnectTimersRef.current.delete(client.name) 279: } 280: const reconnectWithBackoff = async () => { 281: for ( 282: let attempt = 1; 283: attempt <= MAX_RECONNECT_ATTEMPTS; 284: attempt++ 285: ) { 286: if (isMcpServerDisabled(client.name)) { 287: logMCPDebug( 288: client.name, 289: `Server disabled during reconnection, stopping retry`, 290: ) 291: reconnectTimersRef.current.delete(client.name) 292: return 293: } 294: updateServer({ 295: ...client, 296: type: 'pending', 297: reconnectAttempt: attempt, 298: maxReconnectAttempts: MAX_RECONNECT_ATTEMPTS, 299: }) 300: const reconnectStartTime = Date.now() 301: try { 302: const result = await reconnectMcpServerImpl( 303: client.name, 304: client.config, 305: ) 306: const elapsed = Date.now() - reconnectStartTime 307: if (result.client.type === 'connected') { 308: logMCPDebug( 309: client.name, 310: `${transportType} reconnection successful after ${elapsed}ms (attempt ${attempt})`, 311: ) 312: reconnectTimersRef.current.delete(client.name) 313: onConnectionAttempt(result) 314: return 315: } 316: logMCPDebug( 317: client.name, 318: `${transportType} reconnection attempt ${attempt} completed with status: ${result.client.type}`, 319: ) 320: if (attempt === MAX_RECONNECT_ATTEMPTS) { 321: logMCPDebug( 322: client.name, 323: `Max reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`, 324: ) 325: reconnectTimersRef.current.delete(client.name) 326: onConnectionAttempt(result) 327: return 328: } 329: } catch (error) { 330: const elapsed = Date.now() - reconnectStartTime 331: logMCPError( 332: client.name, 333: `${transportType} reconnection attempt ${attempt} failed after ${elapsed}ms: ${error}`, 334: ) 335: if (attempt === MAX_RECONNECT_ATTEMPTS) { 336: logMCPDebug( 337: client.name, 338: `Max reconnection attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`, 339: ) 340: reconnectTimersRef.current.delete(client.name) 341: updateServer({ ...client, type: 'failed' }) 342: return 343: } 344: } 345: const backoffMs = Math.min( 346: INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1), 347: MAX_BACKOFF_MS, 348: ) 349: logMCPDebug( 350: client.name, 351: `Scheduling reconnection attempt ${attempt + 1} in ${backoffMs}ms`, 352: ) 353: await new Promise<void>(resolve => { 354: const timer = setTimeout(resolve, backoffMs) 355: reconnectTimersRef.current.set(client.name, timer) 356: }) 357: } 358: } 359: void reconnectWithBackoff() 360: } else { 361: updateServer({ ...client, type: 'failed' }) 362: } 363: } 364: if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { 365: const gate = gateChannelServer( 366: client.name, 367: client.capabilities, 368: client.config.pluginSource, 369: ) 370: const entry = findChannelEntry(client.name, getAllowedChannels()) 371: const pluginId = 372: entry?.kind === 'plugin' 373: ? (`${entry.name}@${entry.marketplace}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 374: : undefined 375: if (gate.action === 'register' || gate.kind !== 'capability') { 376: logEvent('tengu_mcp_channel_gate', { 377: registered: gate.action === 'register', 378: skip_kind: 379: gate.action === 'skip' 380: ? (gate.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 381: : undefined, 382: entry_kind: 383: entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 384: is_dev: entry?.dev ?? false, 385: plugin: pluginId, 386: }) 387: } 388: switch (gate.action) { 389: case 'register': 390: logMCPDebug(client.name, 'Channel notifications registered') 391: client.client.setNotificationHandler( 392: ChannelMessageNotificationSchema(), 393: async notification => { 394: const { content, meta } = notification.params 395: logMCPDebug( 396: client.name, 397: `notifications/claude/channel: ${content.slice(0, 80)}`, 398: ) 399: logEvent('tengu_mcp_channel_message', { 400: content_length: content.length, 401: meta_key_count: Object.keys(meta ?? {}).length, 402: entry_kind: 403: entry?.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 404: is_dev: entry?.dev ?? false, 405: plugin: pluginId, 406: }) 407: enqueue({ 408: mode: 'prompt', 409: value: wrapChannelMessage(client.name, content, meta), 410: priority: 'next', 411: isMeta: true, 412: origin: { kind: 'channel', server: client.name }, 413: skipSlashCommands: true, 414: }) 415: }, 416: ) 417: if ( 418: client.capabilities?.experimental?.[ 419: 'claude/channel/permission' 420: ] !== undefined 421: ) { 422: client.client.setNotificationHandler( 423: ChannelPermissionNotificationSchema(), 424: async notification => { 425: const { request_id, behavior } = notification.params 426: const resolved = 427: channelPermCallbacksRef.current?.resolve( 428: request_id, 429: behavior, 430: client.name, 431: ) ?? false 432: logMCPDebug( 433: client.name, 434: `notifications/claude/channel/permission: ${request_id} → ${behavior} (${resolved ? 'matched pending' : 'no pending entry — stale or unknown ID'})`, 435: ) 436: }, 437: ) 438: } 439: break 440: case 'skip': 441: client.client.removeNotificationHandler( 442: 'notifications/claude/channel', 443: ) 444: client.client.removeNotificationHandler( 445: CHANNEL_PERMISSION_METHOD, 446: ) 447: logMCPDebug( 448: client.name, 449: `Channel notifications skipped: ${gate.reason}`, 450: ) 451: if ( 452: gate.kind !== 'capability' && 453: gate.kind !== 'session' && 454: !channelWarnedKindsRef.current.has(gate.kind) && 455: (gate.kind === 'marketplace' || 456: gate.kind === 'allowlist' || 457: entry !== undefined) 458: ) { 459: channelWarnedKindsRef.current.add(gate.kind) 460: const text = 461: gate.kind === 'disabled' 462: ? 'Channels are not currently available' 463: : gate.kind === 'auth' 464: ? 'Channels require claude.ai authentication · run /login' 465: : gate.kind === 'policy' 466: ? 'Channels are not enabled for your org · have an administrator set channelsEnabled: true in managed settings' 467: : gate.reason 468: addNotification({ 469: key: `channels-blocked-${gate.kind}`, 470: priority: 'high', 471: text, 472: color: 'warning', 473: timeoutMs: 12000, 474: }) 475: } 476: break 477: } 478: } 479: if (client.capabilities?.tools?.listChanged) { 480: client.client.setNotificationHandler( 481: ToolListChangedNotificationSchema, 482: async () => { 483: logMCPDebug( 484: client.name, 485: `Received tools/list_changed notification, refreshing tools`, 486: ) 487: try { 488: const previousToolsPromise = fetchToolsForClient.cache.get( 489: client.name, 490: ) 491: fetchToolsForClient.cache.delete(client.name) 492: const newTools = await fetchToolsForClient(client) 493: const newCount = newTools.length 494: if (previousToolsPromise) { 495: previousToolsPromise.then( 496: (previousTools: Tool[]) => { 497: logEvent('tengu_mcp_list_changed', { 498: type: 'tools' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 499: previousCount: previousTools.length, 500: newCount, 501: }) 502: }, 503: () => { 504: logEvent('tengu_mcp_list_changed', { 505: type: 'tools' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 506: newCount, 507: }) 508: }, 509: ) 510: } else { 511: logEvent('tengu_mcp_list_changed', { 512: type: 'tools' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 513: newCount, 514: }) 515: } 516: updateServer({ ...client, tools: newTools }) 517: } catch (error) { 518: logMCPError( 519: client.name, 520: `Failed to refresh tools after list_changed notification: ${errorMessage(error)}`, 521: ) 522: } 523: }, 524: ) 525: } 526: if (client.capabilities?.prompts?.listChanged) { 527: client.client.setNotificationHandler( 528: PromptListChangedNotificationSchema, 529: async () => { 530: logMCPDebug( 531: client.name, 532: `Received prompts/list_changed notification, refreshing prompts`, 533: ) 534: logEvent('tengu_mcp_list_changed', { 535: type: 'prompts' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 536: }) 537: try { 538: fetchCommandsForClient.cache.delete(client.name) 539: const [mcpPrompts, mcpSkills] = await Promise.all([ 540: fetchCommandsForClient(client), 541: feature('MCP_SKILLS') 542: ? fetchMcpSkillsForClient!(client) 543: : Promise.resolve([]), 544: ]) 545: updateServer({ 546: ...client, 547: commands: [...mcpPrompts, ...mcpSkills], 548: }) 549: clearSkillIndexCache?.() 550: } catch (error) { 551: logMCPError( 552: client.name, 553: `Failed to refresh prompts after list_changed notification: ${errorMessage(error)}`, 554: ) 555: } 556: }, 557: ) 558: } 559: if (client.capabilities?.resources?.listChanged) { 560: client.client.setNotificationHandler( 561: ResourceListChangedNotificationSchema, 562: async () => { 563: logMCPDebug( 564: client.name, 565: `Received resources/list_changed notification, refreshing resources`, 566: ) 567: logEvent('tengu_mcp_list_changed', { 568: type: 'resources' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 569: }) 570: try { 571: fetchResourcesForClient.cache.delete(client.name) 572: if (feature('MCP_SKILLS')) { 573: fetchMcpSkillsForClient!.cache.delete(client.name) 574: fetchCommandsForClient.cache.delete(client.name) 575: const [newResources, mcpPrompts, mcpSkills] = 576: await Promise.all([ 577: fetchResourcesForClient(client), 578: fetchCommandsForClient(client), 579: fetchMcpSkillsForClient!(client), 580: ]) 581: updateServer({ 582: ...client, 583: resources: newResources, 584: commands: [...mcpPrompts, ...mcpSkills], 585: }) 586: clearSkillIndexCache?.() 587: } else { 588: const newResources = await fetchResourcesForClient(client) 589: updateServer({ ...client, resources: newResources }) 590: } 591: } catch (error) { 592: logMCPError( 593: client.name, 594: `Failed to refresh resources after list_changed notification: ${errorMessage(error)}`, 595: ) 596: } 597: }, 598: ) 599: } 600: break 601: } 602: case 'needs-auth': 603: case 'failed': 604: case 'pending': 605: case 'disabled': 606: break 607: } 608: }, 609: [updateServer], 610: ) 611: const sessionId = getSessionId() 612: useEffect(() => { 613: async function initializeServersAsPending() { 614: const { servers: existingConfigs, errors: mcpErrors } = isStrictMcpConfig 615: ? { servers: {}, errors: [] } 616: : await getClaudeCodeMcpConfigs(dynamicMcpConfig) 617: const configs = { ...existingConfigs, ...dynamicMcpConfig } 618: addErrorsToAppState(setAppState, mcpErrors) 619: setAppState(prevState => { 620: const { stale, ...mcpWithoutStale } = excludeStalePluginClients( 621: prevState.mcp, 622: configs, 623: ) 624: for (const s of stale) { 625: const timer = reconnectTimersRef.current.get(s.name) 626: if (timer) { 627: clearTimeout(timer) 628: reconnectTimersRef.current.delete(s.name) 629: } 630: if (s.type === 'connected') { 631: s.client.onclose = undefined 632: void clearServerCache(s.name, s.config).catch(() => {}) 633: } 634: } 635: const existingServerNames = new Set( 636: mcpWithoutStale.clients.map(c => c.name), 637: ) 638: const newClients = Object.entries(configs) 639: .filter(([name]) => !existingServerNames.has(name)) 640: .map(([name, config]) => ({ 641: name, 642: type: isMcpServerDisabled(name) 643: ? ('disabled' as const) 644: : ('pending' as const), 645: config, 646: })) 647: if (newClients.length === 0 && stale.length === 0) { 648: return prevState 649: } 650: return { 651: ...prevState, 652: mcp: { 653: ...prevState.mcp, 654: ...mcpWithoutStale, 655: clients: [...mcpWithoutStale.clients, ...newClients], 656: }, 657: } 658: }) 659: } 660: void initializeServersAsPending().catch(error => { 661: logMCPError( 662: 'useManageMCPConnections', 663: `Failed to initialize servers as pending: ${errorMessage(error)}`, 664: ) 665: }) 666: }, [ 667: isStrictMcpConfig, 668: dynamicMcpConfig, 669: setAppState, 670: sessionId, 671: _pluginReconnectKey, 672: ]) 673: useEffect(() => { 674: let cancelled = false 675: async function loadAndConnectMcpConfigs() { 676: let claudeaiPromise: Promise<Record<string, ScopedMcpServerConfig>> 677: if (isStrictMcpConfig || doesEnterpriseMcpConfigExist()) { 678: claudeaiPromise = Promise.resolve({}) 679: } else { 680: clearClaudeAIMcpConfigsCache() 681: claudeaiPromise = fetchClaudeAIMcpConfigsIfEligible() 682: } 683: const { servers: claudeCodeConfigs, errors: mcpErrors } = 684: isStrictMcpConfig 685: ? { servers: {}, errors: [] } 686: : await getClaudeCodeMcpConfigs(dynamicMcpConfig, claudeaiPromise) 687: if (cancelled) return 688: addErrorsToAppState(setAppState, mcpErrors) 689: const configs = { ...claudeCodeConfigs, ...dynamicMcpConfig } 690: const enabledConfigs = Object.fromEntries( 691: Object.entries(configs).filter(([name]) => !isMcpServerDisabled(name)), 692: ) 693: getMcpToolsCommandsAndResources( 694: onConnectionAttempt, 695: enabledConfigs, 696: ).catch(error => { 697: logMCPError( 698: 'useManageMcpConnections', 699: `Failed to get MCP resources: ${errorMessage(error)}`, 700: ) 701: }) 702: let claudeaiConfigs: Record<string, ScopedMcpServerConfig> = {} 703: if (!isStrictMcpConfig) { 704: claudeaiConfigs = filterMcpServersByPolicy( 705: await claudeaiPromise, 706: ).allowed 707: if (cancelled) return 708: if (Object.keys(claudeaiConfigs).length > 0) { 709: const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers( 710: claudeaiConfigs, 711: configs, 712: ) 713: claudeaiConfigs = dedupedClaudeAi 714: } 715: if (Object.keys(claudeaiConfigs).length > 0) { 716: setAppState(prevState => { 717: const existingServerNames = new Set( 718: prevState.mcp.clients.map(c => c.name), 719: ) 720: const newClients = Object.entries(claudeaiConfigs) 721: .filter(([name]) => !existingServerNames.has(name)) 722: .map(([name, config]) => ({ 723: name, 724: type: isMcpServerDisabled(name) 725: ? ('disabled' as const) 726: : ('pending' as const), 727: config, 728: })) 729: if (newClients.length === 0) return prevState 730: return { 731: ...prevState, 732: mcp: { 733: ...prevState.mcp, 734: clients: [...prevState.mcp.clients, ...newClients], 735: }, 736: } 737: }) 738: const enabledClaudeaiConfigs = Object.fromEntries( 739: Object.entries(claudeaiConfigs).filter( 740: ([name]) => !isMcpServerDisabled(name), 741: ), 742: ) 743: getMcpToolsCommandsAndResources( 744: onConnectionAttempt, 745: enabledClaudeaiConfigs, 746: ).catch(error => { 747: logMCPError( 748: 'useManageMcpConnections', 749: `Failed to get claude.ai MCP resources: ${errorMessage(error)}`, 750: ) 751: }) 752: } 753: } 754: const allConfigs = { ...configs, ...claudeaiConfigs } 755: const counts = { 756: enterprise: 0, 757: global: 0, 758: project: 0, 759: user: 0, 760: plugin: 0, 761: claudeai: 0, 762: } 763: const stdioCommands: string[] = [] 764: for (const [name, serverConfig] of Object.entries(allConfigs)) { 765: if (serverConfig.scope === 'enterprise') counts.enterprise++ 766: else if (serverConfig.scope === 'user') counts.global++ 767: else if (serverConfig.scope === 'project') counts.project++ 768: else if (serverConfig.scope === 'local') counts.user++ 769: else if (serverConfig.scope === 'dynamic') counts.plugin++ 770: else if (serverConfig.scope === 'claudeai') counts.claudeai++ 771: if ( 772: process.env.USER_TYPE === 'ant' && 773: !isMcpServerDisabled(name) && 774: (serverConfig.type === undefined || serverConfig.type === 'stdio') && 775: 'command' in serverConfig 776: ) { 777: stdioCommands.push(basename(serverConfig.command)) 778: } 779: } 780: logEvent('tengu_mcp_servers', { 781: ...counts, 782: ...(process.env.USER_TYPE === 'ant' && stdioCommands.length > 0 783: ? { 784: stdio_commands: stdioCommands 785: .sort() 786: .join( 787: ',', 788: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 789: } 790: : {}), 791: }) 792: } 793: void loadAndConnectMcpConfigs() 794: return () => { 795: cancelled = true 796: } 797: }, [ 798: isStrictMcpConfig, 799: dynamicMcpConfig, 800: onConnectionAttempt, 801: setAppState, 802: _authVersion, 803: sessionId, 804: _pluginReconnectKey, 805: ]) 806: useEffect(() => { 807: const timers = reconnectTimersRef.current 808: return () => { 809: for (const timer of timers.values()) { 810: clearTimeout(timer) 811: } 812: timers.clear() 813: if (flushTimerRef.current !== null) { 814: clearTimeout(flushTimerRef.current) 815: flushTimerRef.current = null 816: flushPendingUpdates() 817: } 818: } 819: }, [flushPendingUpdates]) 820: const reconnectMcpServer = useCallback( 821: async (serverName: string) => { 822: const client = store 823: .getState() 824: .mcp.clients.find(c => c.name === serverName) 825: if (!client) { 826: throw new Error(`MCP server ${serverName} not found`) 827: } 828: const existingTimer = reconnectTimersRef.current.get(serverName) 829: if (existingTimer) { 830: clearTimeout(existingTimer) 831: reconnectTimersRef.current.delete(serverName) 832: } 833: const result = await reconnectMcpServerImpl(serverName, client.config) 834: onConnectionAttempt(result) 835: return result 836: }, 837: [store, onConnectionAttempt], 838: ) 839: const toggleMcpServer = useCallback( 840: async (serverName: string): Promise<void> => { 841: const client = store 842: .getState() 843: .mcp.clients.find(c => c.name === serverName) 844: if (!client) { 845: throw new Error(`MCP server ${serverName} not found`) 846: } 847: const isCurrentlyDisabled = client.type === 'disabled' 848: if (!isCurrentlyDisabled) { 849: const existingTimer = reconnectTimersRef.current.get(serverName) 850: if (existingTimer) { 851: clearTimeout(existingTimer) 852: reconnectTimersRef.current.delete(serverName) 853: } 854: setMcpServerEnabled(serverName, false) 855: if (client.type === 'connected') { 856: await clearServerCache(serverName, client.config) 857: } 858: updateServer({ 859: name: serverName, 860: type: 'disabled', 861: config: client.config, 862: }) 863: } else { 864: setMcpServerEnabled(serverName, true) 865: updateServer({ 866: name: serverName, 867: type: 'pending', 868: config: client.config, 869: }) 870: const result = await reconnectMcpServerImpl(serverName, client.config) 871: onConnectionAttempt(result) 872: } 873: }, 874: [store, updateServer, onConnectionAttempt], 875: ) 876: return { reconnectMcpServer, toggleMcpServer } 877: } 878: function getTransportDisplayName(type: string): string { 879: switch (type) { 880: case 'http': 881: return 'HTTP' 882: case 'ws': 883: case 'ws-ide': 884: return 'WebSocket' 885: default: 886: return 'SSE' 887: } 888: }

File: src/services/mcp/utils.ts

typescript 1: import { createHash } from 'crypto' 2: import { join } from 'path' 3: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 4: import type { Command } from '../../commands.js' 5: import type { AgentMcpServerInfo } from '../../components/mcp/types.js' 6: import type { Tool } from '../../Tool.js' 7: import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 8: import { getCwd } from '../../utils/cwd.js' 9: import { getGlobalClaudeFile } from '../../utils/env.js' 10: import { isSettingSourceEnabled } from '../../utils/settings/constants.js' 11: import { 12: getSettings_DEPRECATED, 13: hasSkipDangerousModePermissionPrompt, 14: } from '../../utils/settings/settings.js' 15: import { jsonStringify } from '../../utils/slowOperations.js' 16: import { getEnterpriseMcpFilePath, getMcpConfigByName } from './config.js' 17: import { mcpInfoFromString } from './mcpStringUtils.js' 18: import { normalizeNameForMCP } from './normalization.js' 19: import { 20: type ConfigScope, 21: ConfigScopeSchema, 22: type MCPServerConnection, 23: type McpHTTPServerConfig, 24: type McpServerConfig, 25: type McpSSEServerConfig, 26: type McpStdioServerConfig, 27: type McpWebSocketServerConfig, 28: type ScopedMcpServerConfig, 29: type ServerResource, 30: } from './types.js' 31: export function filterToolsByServer(tools: Tool[], serverName: string): Tool[] { 32: const prefix = `mcp__${normalizeNameForMCP(serverName)}__` 33: return tools.filter(tool => tool.name?.startsWith(prefix)) 34: } 35: export function commandBelongsToServer( 36: command: Command, 37: serverName: string, 38: ): boolean { 39: const normalized = normalizeNameForMCP(serverName) 40: const name = command.name 41: if (!name) return false 42: return ( 43: name.startsWith(`mcp__${normalized}__`) || name.startsWith(`${normalized}:`) 44: ) 45: } 46: export function filterCommandsByServer( 47: commands: Command[], 48: serverName: string, 49: ): Command[] { 50: return commands.filter(c => commandBelongsToServer(c, serverName)) 51: } 52: export function filterMcpPromptsByServer( 53: commands: Command[], 54: serverName: string, 55: ): Command[] { 56: return commands.filter( 57: c => 58: commandBelongsToServer(c, serverName) && 59: !(c.type === 'prompt' && c.loadedFrom === 'mcp'), 60: ) 61: } 62: export function filterResourcesByServer( 63: resources: ServerResource[], 64: serverName: string, 65: ): ServerResource[] { 66: return resources.filter(resource => resource.server === serverName) 67: } 68: export function excludeToolsByServer( 69: tools: Tool[], 70: serverName: string, 71: ): Tool[] { 72: const prefix = `mcp__${normalizeNameForMCP(serverName)}__` 73: return tools.filter(tool => !tool.name?.startsWith(prefix)) 74: } 75: export function excludeCommandsByServer( 76: commands: Command[], 77: serverName: string, 78: ): Command[] { 79: return commands.filter(c => !commandBelongsToServer(c, serverName)) 80: } 81: export function excludeResourcesByServer( 82: resources: Record<string, ServerResource[]>, 83: serverName: string, 84: ): Record<string, ServerResource[]> { 85: const result = { ...resources } 86: delete result[serverName] 87: return result 88: } 89: export function hashMcpConfig(config: ScopedMcpServerConfig): string { 90: const { scope: _scope, ...rest } = config 91: const stable = jsonStringify(rest, (_k, v: unknown) => { 92: if (v && typeof v === 'object' && !Array.isArray(v)) { 93: const obj = v as Record<string, unknown> 94: const sorted: Record<string, unknown> = {} 95: for (const k of Object.keys(obj).sort()) sorted[k] = obj[k] 96: return sorted 97: } 98: return v 99: }) 100: return createHash('sha256').update(stable).digest('hex').slice(0, 16) 101: } 102: export function excludeStalePluginClients( 103: mcp: { 104: clients: MCPServerConnection[] 105: tools: Tool[] 106: commands: Command[] 107: resources: Record<string, ServerResource[]> 108: }, 109: configs: Record<string, ScopedMcpServerConfig>, 110: ): { 111: clients: MCPServerConnection[] 112: tools: Tool[] 113: commands: Command[] 114: resources: Record<string, ServerResource[]> 115: stale: MCPServerConnection[] 116: } { 117: const stale = mcp.clients.filter(c => { 118: const fresh = configs[c.name] 119: if (!fresh) return c.config.scope === 'dynamic' 120: return hashMcpConfig(c.config) !== hashMcpConfig(fresh) 121: }) 122: if (stale.length === 0) { 123: return { ...mcp, stale: [] } 124: } 125: let { tools, commands, resources } = mcp 126: for (const s of stale) { 127: tools = excludeToolsByServer(tools, s.name) 128: commands = excludeCommandsByServer(commands, s.name) 129: resources = excludeResourcesByServer(resources, s.name) 130: } 131: const staleNames = new Set(stale.map(c => c.name)) 132: return { 133: clients: mcp.clients.filter(c => !staleNames.has(c.name)), 134: tools, 135: commands, 136: resources, 137: stale, 138: } 139: } 140: export function isToolFromMcpServer( 141: toolName: string, 142: serverName: string, 143: ): boolean { 144: const info = mcpInfoFromString(toolName) 145: return info?.serverName === serverName 146: } 147: export function isMcpTool(tool: Tool): boolean { 148: return tool.name?.startsWith('mcp__') || tool.isMcp === true 149: } 150: export function isMcpCommand(command: Command): boolean { 151: return command.name?.startsWith('mcp__') || command.isMcp === true 152: } 153: export function describeMcpConfigFilePath(scope: ConfigScope): string { 154: switch (scope) { 155: case 'user': 156: return getGlobalClaudeFile() 157: case 'project': 158: return join(getCwd(), '.mcp.json') 159: case 'local': 160: return `${getGlobalClaudeFile()} [project: ${getCwd()}]` 161: case 'dynamic': 162: return 'Dynamically configured' 163: case 'enterprise': 164: return getEnterpriseMcpFilePath() 165: case 'claudeai': 166: return 'claude.ai' 167: default: 168: return scope 169: } 170: } 171: export function getScopeLabel(scope: ConfigScope): string { 172: switch (scope) { 173: case 'local': 174: return 'Local config (private to you in this project)' 175: case 'project': 176: return 'Project config (shared via .mcp.json)' 177: case 'user': 178: return 'User config (available in all your projects)' 179: case 'dynamic': 180: return 'Dynamic config (from command line)' 181: case 'enterprise': 182: return 'Enterprise config (managed by your organization)' 183: case 'claudeai': 184: return 'claude.ai config' 185: default: 186: return scope 187: } 188: } 189: export function ensureConfigScope(scope?: string): ConfigScope { 190: if (!scope) return 'local' 191: if (!ConfigScopeSchema().options.includes(scope as ConfigScope)) { 192: throw new Error( 193: `Invalid scope: ${scope}. Must be one of: ${ConfigScopeSchema().options.join(', ')}`, 194: ) 195: } 196: return scope as ConfigScope 197: } 198: export function ensureTransport(type?: string): 'stdio' | 'sse' | 'http' { 199: if (!type) return 'stdio' 200: if (type !== 'stdio' && type !== 'sse' && type !== 'http') { 201: throw new Error( 202: `Invalid transport type: ${type}. Must be one of: stdio, sse, http`, 203: ) 204: } 205: return type as 'stdio' | 'sse' | 'http' 206: } 207: export function parseHeaders(headerArray: string[]): Record<string, string> { 208: const headers: Record<string, string> = {} 209: for (const header of headerArray) { 210: const colonIndex = header.indexOf(':') 211: if (colonIndex === -1) { 212: throw new Error( 213: `Invalid header format: "${header}". Expected format: "Header-Name: value"`, 214: ) 215: } 216: const key = header.substring(0, colonIndex).trim() 217: const value = header.substring(colonIndex + 1).trim() 218: if (!key) { 219: throw new Error( 220: `Invalid header: "${header}". Header name cannot be empty.`, 221: ) 222: } 223: headers[key] = value 224: } 225: return headers 226: } 227: export function getProjectMcpServerStatus( 228: serverName: string, 229: ): 'approved' | 'rejected' | 'pending' { 230: const settings = getSettings_DEPRECATED() 231: const normalizedName = normalizeNameForMCP(serverName) 232: if ( 233: settings?.disabledMcpjsonServers?.some( 234: name => normalizeNameForMCP(name) === normalizedName, 235: ) 236: ) { 237: return 'rejected' 238: } 239: if ( 240: settings?.enabledMcpjsonServers?.some( 241: name => normalizeNameForMCP(name) === normalizedName, 242: ) || 243: settings?.enableAllProjectMcpServers 244: ) { 245: return 'approved' 246: } 247: if ( 248: hasSkipDangerousModePermissionPrompt() && 249: isSettingSourceEnabled('projectSettings') 250: ) { 251: return 'approved' 252: } 253: if ( 254: getIsNonInteractiveSession() && 255: isSettingSourceEnabled('projectSettings') 256: ) { 257: return 'approved' 258: } 259: return 'pending' 260: } 261: export function getMcpServerScopeFromToolName( 262: toolName: string, 263: ): ConfigScope | null { 264: if (!isMcpTool({ name: toolName } as Tool)) { 265: return null 266: } 267: const mcpInfo = mcpInfoFromString(toolName) 268: if (!mcpInfo) { 269: return null 270: } 271: const serverConfig = getMcpConfigByName(mcpInfo.serverName) 272: if (!serverConfig && mcpInfo.serverName.startsWith('claude_ai_')) { 273: return 'claudeai' 274: } 275: return serverConfig?.scope ?? null 276: } 277: function isStdioConfig( 278: config: McpServerConfig, 279: ): config is McpStdioServerConfig { 280: return config.type === 'stdio' || config.type === undefined 281: } 282: function isSSEConfig(config: McpServerConfig): config is McpSSEServerConfig { 283: return config.type === 'sse' 284: } 285: function isHTTPConfig(config: McpServerConfig): config is McpHTTPServerConfig { 286: return config.type === 'http' 287: } 288: function isWebSocketConfig( 289: config: McpServerConfig, 290: ): config is McpWebSocketServerConfig { 291: return config.type === 'ws' 292: } 293: export function extractAgentMcpServers( 294: agents: AgentDefinition[], 295: ): AgentMcpServerInfo[] { 296: const serverMap = new Map< 297: string, 298: { 299: config: McpServerConfig & { name: string } 300: sourceAgents: string[] 301: } 302: >() 303: for (const agent of agents) { 304: if (!agent.mcpServers?.length) continue 305: for (const spec of agent.mcpServers) { 306: if (typeof spec === 'string') continue 307: const entries = Object.entries(spec) 308: if (entries.length !== 1) continue 309: const [serverName, serverConfig] = entries[0]! 310: const existing = serverMap.get(serverName) 311: if (existing) { 312: if (!existing.sourceAgents.includes(agent.agentType)) { 313: existing.sourceAgents.push(agent.agentType) 314: } 315: } else { 316: serverMap.set(serverName, { 317: config: { ...serverConfig, name: serverName } as McpServerConfig & { 318: name: string 319: }, 320: sourceAgents: [agent.agentType], 321: }) 322: } 323: } 324: } 325: const result: AgentMcpServerInfo[] = [] 326: for (const [name, { config, sourceAgents }] of serverMap) { 327: if (isStdioConfig(config)) { 328: result.push({ 329: name, 330: sourceAgents, 331: transport: 'stdio', 332: command: config.command, 333: needsAuth: false, 334: }) 335: } else if (isSSEConfig(config)) { 336: result.push({ 337: name, 338: sourceAgents, 339: transport: 'sse', 340: url: config.url, 341: needsAuth: true, 342: }) 343: } else if (isHTTPConfig(config)) { 344: result.push({ 345: name, 346: sourceAgents, 347: transport: 'http', 348: url: config.url, 349: needsAuth: true, 350: }) 351: } else if (isWebSocketConfig(config)) { 352: result.push({ 353: name, 354: sourceAgents, 355: transport: 'ws', 356: url: config.url, 357: needsAuth: false, 358: }) 359: } 360: } 361: return result.sort((a, b) => a.name.localeCompare(b.name)) 362: } 363: export function getLoggingSafeMcpBaseUrl( 364: config: McpServerConfig, 365: ): string | undefined { 366: if (!('url' in config) || typeof config.url !== 'string') { 367: return undefined 368: } 369: try { 370: const url = new URL(config.url) 371: url.search = '' 372: return url.toString().replace(/\/$/, '') 373: } catch { 374: return undefined 375: } 376: }

File: src/services/mcp/vscodeSdkMcp.ts

typescript 1: import { logForDebugging } from 'src/utils/debug.js' 2: import { z } from 'zod/v4' 3: import { lazySchema } from '../../utils/lazySchema.js' 4: import { 5: checkStatsigFeatureGate_CACHED_MAY_BE_STALE, 6: getFeatureValue_CACHED_MAY_BE_STALE, 7: } from '../analytics/growthbook.js' 8: import { logEvent } from '../analytics/index.js' 9: import type { ConnectedMCPServer, MCPServerConnection } from './types.js' 10: type AutoModeEnabledState = 'enabled' | 'disabled' | 'opt-in' 11: function readAutoModeEnabledState(): AutoModeEnabledState | undefined { 12: const v = getFeatureValue_CACHED_MAY_BE_STALE<{ enabled?: string }>( 13: 'tengu_auto_mode_config', 14: {}, 15: )?.enabled 16: return v === 'enabled' || v === 'disabled' || v === 'opt-in' ? v : undefined 17: } 18: export const LogEventNotificationSchema = lazySchema(() => 19: z.object({ 20: method: z.literal('log_event'), 21: params: z.object({ 22: eventName: z.string(), 23: eventData: z.object({}).passthrough(), 24: }), 25: }), 26: ) 27: let vscodeMcpClient: ConnectedMCPServer | null = null 28: export function notifyVscodeFileUpdated( 29: filePath: string, 30: oldContent: string | null, 31: newContent: string | null, 32: ): void { 33: if (process.env.USER_TYPE !== 'ant' || !vscodeMcpClient) { 34: return 35: } 36: void vscodeMcpClient.client 37: .notification({ 38: method: 'file_updated', 39: params: { filePath, oldContent, newContent }, 40: }) 41: .catch((error: Error) => { 42: logForDebugging( 43: `[VSCode] Failed to send file_updated notification: ${error.message}`, 44: ) 45: }) 46: } 47: export function setupVscodeSdkMcp(sdkClients: MCPServerConnection[]): void { 48: const client = sdkClients.find(client => client.name === 'claude-vscode') 49: if (client && client.type === 'connected') { 50: vscodeMcpClient = client 51: client.client.setNotificationHandler( 52: LogEventNotificationSchema(), 53: async notification => { 54: const { eventName, eventData } = notification.params 55: logEvent( 56: `tengu_vscode_${eventName}`, 57: eventData as { [key: string]: boolean | number | undefined }, 58: ) 59: }, 60: ) 61: const gates: Record<string, boolean | string> = { 62: tengu_vscode_review_upsell: checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 63: 'tengu_vscode_review_upsell', 64: ), 65: tengu_vscode_onboarding: checkStatsigFeatureGate_CACHED_MAY_BE_STALE( 66: 'tengu_vscode_onboarding', 67: ), 68: tengu_quiet_fern: getFeatureValue_CACHED_MAY_BE_STALE( 69: 'tengu_quiet_fern', 70: false, 71: ), 72: tengu_vscode_cc_auth: getFeatureValue_CACHED_MAY_BE_STALE( 73: 'tengu_vscode_cc_auth', 74: false, 75: ), 76: } 77: const autoModeState = readAutoModeEnabledState() 78: if (autoModeState !== undefined) { 79: gates.tengu_auto_mode_state = autoModeState 80: } 81: void client.client.notification({ 82: method: 'experiment_gates', 83: params: { gates }, 84: }) 85: } 86: }

File: src/services/mcp/xaa.ts

typescript 1: import { 2: discoverAuthorizationServerMetadata, 3: discoverOAuthProtectedResourceMetadata, 4: } from '@modelcontextprotocol/sdk/client/auth.js' 5: import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport.js' 6: import { z } from 'zod/v4' 7: import { lazySchema } from '../../utils/lazySchema.js' 8: import { logMCPDebug } from '../../utils/log.js' 9: import { jsonStringify } from '../../utils/slowOperations.js' 10: const XAA_REQUEST_TIMEOUT_MS = 30000 11: const TOKEN_EXCHANGE_GRANT = 'urn:ietf:params:oauth:grant-type:token-exchange' 12: const JWT_BEARER_GRANT = 'urn:ietf:params:oauth:grant-type:jwt-bearer' 13: const ID_JAG_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id-jag' 14: const ID_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:id_token' 15: function makeXaaFetch(abortSignal?: AbortSignal): FetchLike { 16: return (url, init) => { 17: const timeout = AbortSignal.timeout(XAA_REQUEST_TIMEOUT_MS) 18: const signal = abortSignal 19: ? 20: AbortSignal.any([timeout, abortSignal]) 21: : timeout 22: return fetch(url, { ...init, signal }) 23: } 24: } 25: const defaultFetch = makeXaaFetch() 26: function normalizeUrl(url: string): string { 27: try { 28: return new URL(url).href.replace(/\/$/, '') 29: } catch { 30: return url.replace(/\/$/, '') 31: } 32: } 33: /** 34: * Thrown by requestJwtAuthorizationGrant when the IdP token-exchange leg 35: * fails. Carries `shouldClearIdToken` so callers can decide whether to drop 36: * the cached id_token based on OAuth error semantics (not substring matching): 37: * - 4xx / invalid_grant / invalid_token → id_token is bad, clear it 38: * - 5xx → IdP is down, id_token may still be valid, keep it 39: * - 200 with structurally-invalid body → protocol violation, clear it 40: */ 41: export class XaaTokenExchangeError extends Error { 42: readonly shouldClearIdToken: boolean 43: constructor(message: string, shouldClearIdToken: boolean) { 44: super(message) 45: this.name = 'XaaTokenExchangeError' 46: this.shouldClearIdToken = shouldClearIdToken 47: } 48: } 49: const SENSITIVE_TOKEN_RE = 50: /"(access_token|refresh_token|id_token|assertion|subject_token|client_secret)"\s*:\s*"[^"]*"/g 51: function redactTokens(raw: unknown): string { 52: const s = typeof raw === 'string' ? raw : jsonStringify(raw) 53: return s.replace(SENSITIVE_TOKEN_RE, (_, k) => `"${k}":"[REDACTED]"`) 54: } 55: // ─── Zod Schemas ──────────────────────────────────────────────────────────── 56: const TokenExchangeResponseSchema = lazySchema(() => 57: z.object({ 58: access_token: z.string().optional(), 59: issued_token_type: z.string().optional(), 60: // z.coerce tolerates IdPs that send expires_in as a string (common in 61: // PHP-backed IdPs) — technically non-conformant JSON but widespread. 62: expires_in: z.coerce.number().optional(), 63: scope: z.string().optional(), 64: }), 65: ) 66: const JwtBearerResponseSchema = lazySchema(() => 67: z.object({ 68: access_token: z.string().min(1), 69: // Many ASes omit token_type since Bearer is the only value anyone uses 70: // (RFC 6750). Don't reject a valid access_token over a missing label. 71: token_type: z.string().default('Bearer'), 72: expires_in: z.coerce.number().optional(), 73: scope: z.string().optional(), 74: refresh_token: z.string().optional(), 75: }), 76: ) 77: // ─── Layer 2: Discovery ───────────────────────────────────────────────────── 78: export type ProtectedResourceMetadata = { 79: resource: string 80: authorization_servers: string[] 81: } 82: /** 83: * RFC 9728 PRM discovery via SDK, plus RFC 9728 §3.3 resource-mismatch 84: * validation (mix-up protection — TODO: upstream to SDK). 85: */ 86: export async function discoverProtectedResource( 87: serverUrl: string, 88: opts?: { fetchFn?: FetchLike }, 89: ): Promise<ProtectedResourceMetadata> { 90: let prm 91: try { 92: prm = await discoverOAuthProtectedResourceMetadata( 93: serverUrl, 94: undefined, 95: opts?.fetchFn ?? defaultFetch, 96: ) 97: } catch (e) { 98: throw new Error( 99: `XAA: PRM discovery failed: ${e instanceof Error ? e.message : String(e)}`, 100: ) 101: } 102: if (!prm.resource || !prm.authorization_servers?.[0]) { 103: throw new Error( 104: 'XAA: PRM discovery failed: PRM missing resource or authorization_servers', 105: ) 106: } 107: if (normalizeUrl(prm.resource) !== normalizeUrl(serverUrl)) { 108: throw new Error( 109: `XAA: PRM discovery failed: PRM resource mismatch: expected ${serverUrl}, got ${prm.resource}`, 110: ) 111: } 112: return { 113: resource: prm.resource, 114: authorization_servers: prm.authorization_servers, 115: } 116: } 117: export type AuthorizationServerMetadata = { 118: issuer: string 119: token_endpoint: string 120: grant_types_supported?: string[] 121: token_endpoint_auth_methods_supported?: string[] 122: } 123: /** 124: * AS metadata discovery via SDK (RFC 8414 + OIDC fallback), plus RFC 8414 125: * §3.3 issuer-mismatch validation (mix-up protection — TODO: upstream to SDK). 126: */ 127: export async function discoverAuthorizationServer( 128: asUrl: string, 129: opts?: { fetchFn?: FetchLike }, 130: ): Promise<AuthorizationServerMetadata> { 131: const meta = await discoverAuthorizationServerMetadata(asUrl, { 132: fetchFn: opts?.fetchFn ?? defaultFetch, 133: }) 134: if (!meta?.issuer || !meta.token_endpoint) { 135: throw new Error( 136: `XAA: AS metadata discovery failed: no valid metadata at ${asUrl}`, 137: ) 138: } 139: if (normalizeUrl(meta.issuer) !== normalizeUrl(asUrl)) { 140: throw new Error( 141: `XAA: AS metadata discovery failed: issuer mismatch: expected ${asUrl}, got ${meta.issuer}`, 142: ) 143: } 144: // RFC 8414 §3.3 / RFC 9728 §3 require HTTPS. A PRM-advertised http:// AS 145: // that self-consistently reports an http:// issuer would pass the mismatch 146: // check above, then we'd POST id_token + client_secret over plaintext. 147: if (new URL(meta.token_endpoint).protocol !== 'https:') { 148: throw new Error( 149: `XAA: refusing non-HTTPS token endpoint: ${meta.token_endpoint}`, 150: ) 151: } 152: return { 153: issuer: meta.issuer, 154: token_endpoint: meta.token_endpoint, 155: grant_types_supported: meta.grant_types_supported, 156: token_endpoint_auth_methods_supported: 157: meta.token_endpoint_auth_methods_supported, 158: } 159: } 160: // ─── Layer 2: Exchange ────────────────────────────────────────────────────── 161: export type JwtAuthGrantResult = { 162: /** The ID-JAG (Identity Assertion Authorization Grant) */ 163: jwtAuthGrant: string 164: expiresIn?: number 165: scope?: string 166: } 167: /** 168: * RFC 8693 Token Exchange at the IdP: id_token → ID-JAG. 169: * Validates `issued_token_type` is `urn:ietf:params:oauth:token-type:id-jag`. 170: * 171: * `clientSecret` is optional — sent via `client_secret_post` if present. 172: * Some IdPs register the client as confidential even when they advertise 173: * `token_endpoint_auth_method: "none"`. 174: * 175: * TODO(xaa-ga): consult `token_endpoint_auth_methods_supported` from IdP 176: * OIDC metadata and support `client_secret_basic`, mirroring the AS-side 177: * selection in `performCrossAppAccess`. All major IdPs accept POST today. 178: */ 179: export async function requestJwtAuthorizationGrant(opts: { 180: tokenEndpoint: string 181: audience: string 182: resource: string 183: idToken: string 184: clientId: string 185: clientSecret?: string 186: scope?: string 187: fetchFn?: FetchLike 188: }): Promise<JwtAuthGrantResult> { 189: const fetchFn = opts.fetchFn ?? defaultFetch 190: const params = new URLSearchParams({ 191: grant_type: TOKEN_EXCHANGE_GRANT, 192: requested_token_type: ID_JAG_TOKEN_TYPE, 193: audience: opts.audience, 194: resource: opts.resource, 195: subject_token: opts.idToken, 196: subject_token_type: ID_TOKEN_TYPE, 197: client_id: opts.clientId, 198: }) 199: if (opts.clientSecret) { 200: params.set('client_secret', opts.clientSecret) 201: } 202: if (opts.scope) { 203: params.set('scope', opts.scope) 204: } 205: const res = await fetchFn(opts.tokenEndpoint, { 206: method: 'POST', 207: headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 208: body: params, 209: }) 210: if (!res.ok) { 211: const body = redactTokens(await res.text()).slice(0, 200) 212: const shouldClear = res.status < 500 213: throw new XaaTokenExchangeError( 214: `XAA: token exchange failed: HTTP ${res.status}: ${body}`, 215: shouldClear, 216: ) 217: } 218: let rawExchange: unknown 219: try { 220: rawExchange = await res.json() 221: } catch { 222: throw new XaaTokenExchangeError( 223: `XAA: token exchange returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`, 224: false, 225: ) 226: } 227: const exchangeParsed = TokenExchangeResponseSchema().safeParse(rawExchange) 228: if (!exchangeParsed.success) { 229: throw new XaaTokenExchangeError( 230: `XAA: token exchange response did not match expected shape: ${redactTokens(rawExchange)}`, 231: true, 232: ) 233: } 234: const result = exchangeParsed.data 235: if (!result.access_token) { 236: throw new XaaTokenExchangeError( 237: `XAA: token exchange response missing access_token: ${redactTokens(result)}`, 238: true, 239: ) 240: } 241: if (result.issued_token_type !== ID_JAG_TOKEN_TYPE) { 242: throw new XaaTokenExchangeError( 243: `XAA: token exchange returned unexpected issued_token_type: ${result.issued_token_type}`, 244: true, 245: ) 246: } 247: return { 248: jwtAuthGrant: result.access_token, 249: expiresIn: result.expires_in, 250: scope: result.scope, 251: } 252: } 253: export type XaaTokenResult = { 254: access_token: string 255: token_type: string 256: expires_in?: number 257: scope?: string 258: refresh_token?: string 259: } 260: export type XaaResult = XaaTokenResult & { 261: authorizationServerUrl: string 262: } 263: export async function exchangeJwtAuthGrant(opts: { 264: tokenEndpoint: string 265: assertion: string 266: clientId: string 267: clientSecret: string 268: authMethod?: 'client_secret_basic' | 'client_secret_post' 269: scope?: string 270: fetchFn?: FetchLike 271: }): Promise<XaaTokenResult> { 272: const fetchFn = opts.fetchFn ?? defaultFetch 273: const authMethod = opts.authMethod ?? 'client_secret_basic' 274: const params = new URLSearchParams({ 275: grant_type: JWT_BEARER_GRANT, 276: assertion: opts.assertion, 277: }) 278: if (opts.scope) { 279: params.set('scope', opts.scope) 280: } 281: const headers: Record<string, string> = { 282: 'Content-Type': 'application/x-www-form-urlencoded', 283: } 284: if (authMethod === 'client_secret_basic') { 285: const basicAuth = Buffer.from( 286: `${encodeURIComponent(opts.clientId)}:${encodeURIComponent(opts.clientSecret)}`, 287: ).toString('base64') 288: headers.Authorization = `Basic ${basicAuth}` 289: } else { 290: params.set('client_id', opts.clientId) 291: params.set('client_secret', opts.clientSecret) 292: } 293: const res = await fetchFn(opts.tokenEndpoint, { 294: method: 'POST', 295: headers, 296: body: params, 297: }) 298: if (!res.ok) { 299: const body = redactTokens(await res.text()).slice(0, 200) 300: throw new Error(`XAA: jwt-bearer grant failed: HTTP ${res.status}: ${body}`) 301: } 302: let rawTokens: unknown 303: try { 304: rawTokens = await res.json() 305: } catch { 306: throw new Error( 307: `XAA: jwt-bearer grant returned non-JSON (captive portal?) at ${opts.tokenEndpoint}`, 308: ) 309: } 310: const tokensParsed = JwtBearerResponseSchema().safeParse(rawTokens) 311: if (!tokensParsed.success) { 312: throw new Error( 313: `XAA: jwt-bearer response did not match expected shape: ${redactTokens(rawTokens)}`, 314: ) 315: } 316: return tokensParsed.data 317: } 318: export type XaaConfig = { 319: clientId: string 320: clientSecret: string 321: idpClientId: string 322: idpClientSecret?: string 323: idpIdToken: string 324: idpTokenEndpoint: string 325: } 326: export async function performCrossAppAccess( 327: serverUrl: string, 328: config: XaaConfig, 329: serverName = 'xaa', 330: abortSignal?: AbortSignal, 331: ): Promise<XaaResult> { 332: const fetchFn = makeXaaFetch(abortSignal) 333: logMCPDebug(serverName, `XAA: discovering PRM for ${serverUrl}`) 334: const prm = await discoverProtectedResource(serverUrl, { fetchFn }) 335: logMCPDebug( 336: serverName, 337: `XAA: discovered resource=${prm.resource} ASes=[${prm.authorization_servers.join(', ')}]`, 338: ) 339: let asMeta: AuthorizationServerMetadata | undefined 340: const asErrors: string[] = [] 341: for (const asUrl of prm.authorization_servers) { 342: let candidate: AuthorizationServerMetadata 343: try { 344: candidate = await discoverAuthorizationServer(asUrl, { fetchFn }) 345: } catch (e) { 346: if (abortSignal?.aborted) throw e 347: asErrors.push(`${asUrl}: ${e instanceof Error ? e.message : String(e)}`) 348: continue 349: } 350: if ( 351: candidate.grant_types_supported && 352: !candidate.grant_types_supported.includes(JWT_BEARER_GRANT) 353: ) { 354: asErrors.push( 355: `${asUrl}: does not advertise jwt-bearer grant (supported: ${candidate.grant_types_supported.join(', ')})`, 356: ) 357: continue 358: } 359: asMeta = candidate 360: break 361: } 362: if (!asMeta) { 363: throw new Error( 364: `XAA: no authorization server supports jwt-bearer. Tried: ${asErrors.join('; ')}`, 365: ) 366: } 367: const authMethods = asMeta.token_endpoint_auth_methods_supported 368: const authMethod: 'client_secret_basic' | 'client_secret_post' = 369: authMethods && 370: !authMethods.includes('client_secret_basic') && 371: authMethods.includes('client_secret_post') 372: ? 'client_secret_post' 373: : 'client_secret_basic' 374: logMCPDebug( 375: serverName, 376: `XAA: AS issuer=${asMeta.issuer} token_endpoint=${asMeta.token_endpoint} auth_method=${authMethod}`, 377: ) 378: logMCPDebug(serverName, `XAA: exchanging id_token for ID-JAG at IdP`) 379: const jag = await requestJwtAuthorizationGrant({ 380: tokenEndpoint: config.idpTokenEndpoint, 381: audience: asMeta.issuer, 382: resource: prm.resource, 383: idToken: config.idpIdToken, 384: clientId: config.idpClientId, 385: clientSecret: config.idpClientSecret, 386: fetchFn, 387: }) 388: logMCPDebug(serverName, `XAA: ID-JAG obtained`) 389: logMCPDebug(serverName, `XAA: exchanging ID-JAG for access_token at AS`) 390: const tokens = await exchangeJwtAuthGrant({ 391: tokenEndpoint: asMeta.token_endpoint, 392: assertion: jag.jwtAuthGrant, 393: clientId: config.clientId, 394: clientSecret: config.clientSecret, 395: authMethod, 396: fetchFn, 397: }) 398: logMCPDebug(serverName, `XAA: access_token obtained`) 399: return { ...tokens, authorizationServerUrl: asMeta.issuer } 400: }

File: src/services/mcp/xaaIdpLogin.ts

typescript 1: import { 2: exchangeAuthorization, 3: startAuthorization, 4: } from '@modelcontextprotocol/sdk/client/auth.js' 5: import { 6: type OAuthClientInformation, 7: type OpenIdProviderDiscoveryMetadata, 8: OpenIdProviderDiscoveryMetadataSchema, 9: } from '@modelcontextprotocol/sdk/shared/auth.js' 10: import { randomBytes } from 'crypto' 11: import { createServer, type Server } from 'http' 12: import { parse } from 'url' 13: import xss from 'xss' 14: import { openBrowser } from '../../utils/browser.js' 15: import { isEnvTruthy } from '../../utils/envUtils.js' 16: import { toError } from '../../utils/errors.js' 17: import { logMCPDebug } from '../../utils/log.js' 18: import { getPlatform } from '../../utils/platform.js' 19: import { getSecureStorage } from '../../utils/secureStorage/index.js' 20: import { getInitialSettings } from '../../utils/settings/settings.js' 21: import { jsonParse } from '../../utils/slowOperations.js' 22: import { buildRedirectUri, findAvailablePort } from './oauthPort.js' 23: export function isXaaEnabled(): boolean { 24: return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_XAA) 25: } 26: export type XaaIdpSettings = { 27: issuer: string 28: clientId: string 29: callbackPort?: number 30: } 31: export function getXaaIdpSettings(): XaaIdpSettings | undefined { 32: return (getInitialSettings() as { xaaIdp?: XaaIdpSettings }).xaaIdp 33: } 34: const IDP_LOGIN_TIMEOUT_MS = 5 * 60 * 1000 35: const IDP_REQUEST_TIMEOUT_MS = 30000 36: const ID_TOKEN_EXPIRY_BUFFER_S = 60 37: export type IdpLoginOptions = { 38: idpIssuer: string 39: idpClientId: string 40: idpClientSecret?: string 41: callbackPort?: number 42: onAuthorizationUrl?: (url: string) => void 43: skipBrowserOpen?: boolean 44: abortSignal?: AbortSignal 45: } 46: export function issuerKey(issuer: string): string { 47: try { 48: const u = new URL(issuer) 49: u.pathname = u.pathname.replace(/\/+$/, '') 50: u.host = u.host.toLowerCase() 51: return u.toString() 52: } catch { 53: return issuer.replace(/\/+$/, '') 54: } 55: } 56: /** 57: * Read a cached id_token for the given IdP issuer from secure storage. 58: * Returns undefined if missing or within ID_TOKEN_EXPIRY_BUFFER_S of expiring. 59: */ 60: export function getCachedIdpIdToken(idpIssuer: string): string | undefined { 61: const storage = getSecureStorage() 62: const data = storage.read() 63: const entry = data?.mcpXaaIdp?.[issuerKey(idpIssuer)] 64: if (!entry) return undefined 65: const remainingMs = entry.expiresAt - Date.now() 66: if (remainingMs <= ID_TOKEN_EXPIRY_BUFFER_S * 1000) return undefined 67: return entry.idToken 68: } 69: function saveIdpIdToken( 70: idpIssuer: string, 71: idToken: string, 72: expiresAt: number, 73: ): void { 74: const storage = getSecureStorage() 75: const existing = storage.read() || {} 76: storage.update({ 77: ...existing, 78: mcpXaaIdp: { 79: ...existing.mcpXaaIdp, 80: [issuerKey(idpIssuer)]: { idToken, expiresAt }, 81: }, 82: }) 83: } 84: /** 85: * Save an externally-obtained id_token into the XAA cache — the exact slot 86: * getCachedIdpIdToken/acquireIdpIdToken read from. Used by conformance testing 87: * where the mock IdP hands us a pre-signed token but doesn't serve /authorize. 88: * 89: * Parses the JWT's exp claim for cache TTL (same as acquireIdpIdToken). 90: * Returns the expiresAt it computed so the caller can report it. 91: */ 92: export function saveIdpIdTokenFromJwt( 93: idpIssuer: string, 94: idToken: string, 95: ): number { 96: const expFromJwt = jwtExp(idToken) 97: const expiresAt = expFromJwt ? expFromJwt * 1000 : Date.now() + 3600 * 1000 98: saveIdpIdToken(idpIssuer, idToken, expiresAt) 99: return expiresAt 100: } 101: export function clearIdpIdToken(idpIssuer: string): void { 102: const storage = getSecureStorage() 103: const existing = storage.read() 104: const key = issuerKey(idpIssuer) 105: if (!existing?.mcpXaaIdp?.[key]) return 106: delete existing.mcpXaaIdp[key] 107: storage.update(existing) 108: } 109: export function saveIdpClientSecret( 110: idpIssuer: string, 111: clientSecret: string, 112: ): { success: boolean; warning?: string } { 113: const storage = getSecureStorage() 114: const existing = storage.read() || {} 115: return storage.update({ 116: ...existing, 117: mcpXaaIdpConfig: { 118: ...existing.mcpXaaIdpConfig, 119: [issuerKey(idpIssuer)]: { clientSecret }, 120: }, 121: }) 122: } 123: export function getIdpClientSecret(idpIssuer: string): string | undefined { 124: const storage = getSecureStorage() 125: const data = storage.read() 126: return data?.mcpXaaIdpConfig?.[issuerKey(idpIssuer)]?.clientSecret 127: } 128: export function clearIdpClientSecret(idpIssuer: string): void { 129: const storage = getSecureStorage() 130: const existing = storage.read() 131: const key = issuerKey(idpIssuer) 132: if (!existing?.mcpXaaIdpConfig?.[key]) return 133: delete existing.mcpXaaIdpConfig[key] 134: storage.update(existing) 135: } 136: export async function discoverOidc( 137: idpIssuer: string, 138: ): Promise<OpenIdProviderDiscoveryMetadata> { 139: const base = idpIssuer.endsWith('/') ? idpIssuer : idpIssuer + '/' 140: const url = new URL('.well-known/openid-configuration', base) 141: const res = await fetch(url, { 142: headers: { Accept: 'application/json' }, 143: signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS), 144: }) 145: if (!res.ok) { 146: throw new Error( 147: `XAA IdP: OIDC discovery failed: HTTP ${res.status} at ${url}`, 148: ) 149: } 150: let body: unknown 151: try { 152: body = await res.json() 153: } catch { 154: throw new Error( 155: `XAA IdP: OIDC discovery returned non-JSON at ${url} (captive portal or proxy?)`, 156: ) 157: } 158: const parsed = OpenIdProviderDiscoveryMetadataSchema.safeParse(body) 159: if (!parsed.success) { 160: throw new Error(`XAA IdP: invalid OIDC metadata: ${parsed.error.message}`) 161: } 162: if (new URL(parsed.data.token_endpoint).protocol !== 'https:') { 163: throw new Error( 164: `XAA IdP: refusing non-HTTPS token endpoint: ${parsed.data.token_endpoint}`, 165: ) 166: } 167: return parsed.data 168: } 169: function jwtExp(jwt: string): number | undefined { 170: const parts = jwt.split('.') 171: if (parts.length !== 3) return undefined 172: try { 173: const payload = jsonParse( 174: Buffer.from(parts[1]!, 'base64url').toString('utf-8'), 175: ) as { exp?: number } 176: return typeof payload.exp === 'number' ? payload.exp : undefined 177: } catch { 178: return undefined 179: } 180: } 181: function waitForCallback( 182: port: number, 183: expectedState: string, 184: abortSignal: AbortSignal | undefined, 185: onListening: () => void, 186: ): Promise<string> { 187: let server: Server | null = null 188: let timeoutId: NodeJS.Timeout | null = null 189: let abortHandler: (() => void) | null = null 190: const cleanup = () => { 191: server?.removeAllListeners() 192: server?.on('error', () => {}) 193: server?.close() 194: server = null 195: if (timeoutId) { 196: clearTimeout(timeoutId) 197: timeoutId = null 198: } 199: if (abortSignal && abortHandler) { 200: abortSignal.removeEventListener('abort', abortHandler) 201: abortHandler = null 202: } 203: } 204: return new Promise<string>((resolve, reject) => { 205: let resolved = false 206: const resolveOnce = (v: string) => { 207: if (resolved) return 208: resolved = true 209: cleanup() 210: resolve(v) 211: } 212: const rejectOnce = (e: Error) => { 213: if (resolved) return 214: resolved = true 215: cleanup() 216: reject(e) 217: } 218: if (abortSignal) { 219: abortHandler = () => rejectOnce(new Error('XAA IdP: login cancelled')) 220: if (abortSignal.aborted) { 221: abortHandler() 222: return 223: } 224: abortSignal.addEventListener('abort', abortHandler, { once: true }) 225: } 226: server = createServer((req, res) => { 227: const parsed = parse(req.url || '', true) 228: if (parsed.pathname !== '/callback') { 229: res.writeHead(404) 230: res.end() 231: return 232: } 233: const code = parsed.query.code as string | undefined 234: const state = parsed.query.state as string | undefined 235: const err = parsed.query.error as string | undefined 236: if (err) { 237: const desc = parsed.query.error_description as string | undefined 238: const safeErr = xss(err) 239: const safeDesc = desc ? xss(desc) : '' 240: res.writeHead(400, { 'Content-Type': 'text/html' }) 241: res.end( 242: `<html><body><h3>IdP login failed</h3><p>${safeErr}</p><p>${safeDesc}</p></body></html>`, 243: ) 244: rejectOnce(new Error(`XAA IdP: ${err}${desc ? ` — ${desc}` : ''}`)) 245: return 246: } 247: if (state !== expectedState) { 248: res.writeHead(400, { 'Content-Type': 'text/html' }) 249: res.end('<html><body><h3>State mismatch</h3></body></html>') 250: rejectOnce(new Error('XAA IdP: state mismatch (possible CSRF)')) 251: return 252: } 253: if (!code) { 254: res.writeHead(400, { 'Content-Type': 'text/html' }) 255: res.end('<html><body><h3>Missing code</h3></body></html>') 256: rejectOnce(new Error('XAA IdP: callback missing code')) 257: return 258: } 259: res.writeHead(200, { 'Content-Type': 'text/html' }) 260: res.end( 261: '<html><body><h3>IdP login complete — you can close this window.</h3></body></html>', 262: ) 263: resolveOnce(code) 264: }) 265: server.on('error', (err: NodeJS.ErrnoException) => { 266: if (err.code === 'EADDRINUSE') { 267: const findCmd = 268: getPlatform() === 'windows' 269: ? `netstat -ano | findstr :${port}` 270: : `lsof -ti:${port} -sTCP:LISTEN` 271: rejectOnce( 272: new Error( 273: `XAA IdP: callback port ${port} is already in use. Run \`${findCmd}\` to find the holder.`, 274: ), 275: ) 276: } else { 277: rejectOnce(new Error(`XAA IdP: callback server failed: ${err.message}`)) 278: } 279: }) 280: server.listen(port, '127.0.0.1', () => { 281: try { 282: onListening() 283: } catch (e) { 284: rejectOnce(toError(e)) 285: } 286: }) 287: server.unref() 288: timeoutId = setTimeout( 289: rej => rej(new Error('XAA IdP: login timed out')), 290: IDP_LOGIN_TIMEOUT_MS, 291: rejectOnce, 292: ) 293: timeoutId.unref() 294: }) 295: } 296: export async function acquireIdpIdToken( 297: opts: IdpLoginOptions, 298: ): Promise<string> { 299: const { idpIssuer, idpClientId } = opts 300: const cached = getCachedIdpIdToken(idpIssuer) 301: if (cached) { 302: logMCPDebug('xaa', `Using cached id_token for ${idpIssuer}`) 303: return cached 304: } 305: logMCPDebug('xaa', `No cached id_token for ${idpIssuer}; starting OIDC login`) 306: const metadata = await discoverOidc(idpIssuer) 307: const port = opts.callbackPort ?? (await findAvailablePort()) 308: const redirectUri = buildRedirectUri(port) 309: const state = randomBytes(32).toString('base64url') 310: const clientInformation: OAuthClientInformation = { 311: client_id: idpClientId, 312: ...(opts.idpClientSecret ? { client_secret: opts.idpClientSecret } : {}), 313: } 314: const { authorizationUrl, codeVerifier } = await startAuthorization( 315: idpIssuer, 316: { 317: metadata, 318: clientInformation, 319: redirectUrl: redirectUri, 320: scope: 'openid', 321: state, 322: }, 323: ) 324: const authorizationCode = await waitForCallback( 325: port, 326: state, 327: opts.abortSignal, 328: () => { 329: if (opts.onAuthorizationUrl) { 330: opts.onAuthorizationUrl(authorizationUrl.toString()) 331: } 332: if (!opts.skipBrowserOpen) { 333: logMCPDebug('xaa', `Opening browser to IdP authorization endpoint`) 334: void openBrowser(authorizationUrl.toString()) 335: } 336: }, 337: ) 338: const tokens = await exchangeAuthorization(idpIssuer, { 339: metadata, 340: clientInformation, 341: authorizationCode, 342: codeVerifier, 343: redirectUri, 344: fetchFn: (url, init) => 345: fetch(url, { 346: ...init, 347: signal: AbortSignal.timeout(IDP_REQUEST_TIMEOUT_MS), 348: }), 349: }) 350: if (!tokens.id_token) { 351: throw new Error( 352: 'XAA IdP: token response missing id_token (check scope=openid)', 353: ) 354: } 355: const expFromJwt = jwtExp(tokens.id_token) 356: const expiresAt = expFromJwt 357: ? expFromJwt * 1000 358: : Date.now() + (tokens.expires_in ?? 3600) * 1000 359: saveIdpIdToken(idpIssuer, tokens.id_token, expiresAt) 360: logMCPDebug( 361: 'xaa', 362: `Cached id_token for ${idpIssuer} (expires ${new Date(expiresAt).toISOString()})`, 363: ) 364: return tokens.id_token 365: }

File: src/services/oauth/auth-code-listener.ts

typescript 1: import type { IncomingMessage, ServerResponse } from 'http' 2: import { createServer, type Server } from 'http' 3: import type { AddressInfo } from 'net' 4: import { logEvent } from 'src/services/analytics/index.js' 5: import { getOauthConfig } from '../../constants/oauth.js' 6: import { logError } from '../../utils/log.js' 7: import { shouldUseClaudeAIAuth } from './client.js' 8: export class AuthCodeListener { 9: private localServer: Server 10: private port: number = 0 11: private promiseResolver: ((authorizationCode: string) => void) | null = null 12: private promiseRejecter: ((error: Error) => void) | null = null 13: private expectedState: string | null = null 14: private pendingResponse: ServerResponse | null = null 15: private callbackPath: string 16: constructor(callbackPath: string = '/callback') { 17: this.localServer = createServer() 18: this.callbackPath = callbackPath 19: } 20: async start(port?: number): Promise<number> { 21: return new Promise((resolve, reject) => { 22: this.localServer.once('error', err => { 23: reject( 24: new Error(`Failed to start OAuth callback server: ${err.message}`), 25: ) 26: }) 27: this.localServer.listen(port ?? 0, 'localhost', () => { 28: const address = this.localServer.address() as AddressInfo 29: this.port = address.port 30: resolve(this.port) 31: }) 32: }) 33: } 34: getPort(): number { 35: return this.port 36: } 37: hasPendingResponse(): boolean { 38: return this.pendingResponse !== null 39: } 40: async waitForAuthorization( 41: state: string, 42: onReady: () => Promise<void>, 43: ): Promise<string> { 44: return new Promise<string>((resolve, reject) => { 45: this.promiseResolver = resolve 46: this.promiseRejecter = reject 47: this.expectedState = state 48: this.startLocalListener(onReady) 49: }) 50: } 51: handleSuccessRedirect( 52: scopes: string[], 53: customHandler?: (res: ServerResponse, scopes: string[]) => void, 54: ): void { 55: if (!this.pendingResponse) return 56: if (customHandler) { 57: customHandler(this.pendingResponse, scopes) 58: this.pendingResponse = null 59: logEvent('tengu_oauth_automatic_redirect', { custom_handler: true }) 60: return 61: } 62: const successUrl = shouldUseClaudeAIAuth(scopes) 63: ? getOauthConfig().CLAUDEAI_SUCCESS_URL 64: : getOauthConfig().CONSOLE_SUCCESS_URL 65: this.pendingResponse.writeHead(302, { Location: successUrl }) 66: this.pendingResponse.end() 67: this.pendingResponse = null 68: logEvent('tengu_oauth_automatic_redirect', {}) 69: } 70: handleErrorRedirect(): void { 71: if (!this.pendingResponse) return 72: const errorUrl = getOauthConfig().CLAUDEAI_SUCCESS_URL 73: this.pendingResponse.writeHead(302, { Location: errorUrl }) 74: this.pendingResponse.end() 75: this.pendingResponse = null 76: logEvent('tengu_oauth_automatic_redirect_error', {}) 77: } 78: private startLocalListener(onReady: () => Promise<void>): void { 79: this.localServer.on('request', this.handleRedirect.bind(this)) 80: this.localServer.on('error', this.handleError.bind(this)) 81: void onReady() 82: } 83: private handleRedirect(req: IncomingMessage, res: ServerResponse): void { 84: const parsedUrl = new URL( 85: req.url || '', 86: `http://${req.headers.host || 'localhost'}`, 87: ) 88: if (parsedUrl.pathname !== this.callbackPath) { 89: res.writeHead(404) 90: res.end() 91: return 92: } 93: const authCode = parsedUrl.searchParams.get('code') ?? undefined 94: const state = parsedUrl.searchParams.get('state') ?? undefined 95: this.validateAndRespond(authCode, state, res) 96: } 97: private validateAndRespond( 98: authCode: string | undefined, 99: state: string | undefined, 100: res: ServerResponse, 101: ): void { 102: if (!authCode) { 103: res.writeHead(400) 104: res.end('Authorization code not found') 105: this.reject(new Error('No authorization code received')) 106: return 107: } 108: if (state !== this.expectedState) { 109: res.writeHead(400) 110: res.end('Invalid state parameter') 111: this.reject(new Error('Invalid state parameter')) 112: return 113: } 114: this.pendingResponse = res 115: this.resolve(authCode) 116: } 117: private handleError(err: Error): void { 118: logError(err) 119: this.close() 120: this.reject(err) 121: } 122: private resolve(authorizationCode: string): void { 123: if (this.promiseResolver) { 124: this.promiseResolver(authorizationCode) 125: this.promiseResolver = null 126: this.promiseRejecter = null 127: } 128: } 129: private reject(error: Error): void { 130: if (this.promiseRejecter) { 131: this.promiseRejecter(error) 132: this.promiseResolver = null 133: this.promiseRejecter = null 134: } 135: } 136: close(): void { 137: if (this.pendingResponse) { 138: this.handleErrorRedirect() 139: } 140: if (this.localServer) { 141: this.localServer.removeAllListeners() 142: this.localServer.close() 143: } 144: } 145: }

File: src/services/oauth/client.ts

typescript 1: import axios from 'axios' 2: import { 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: logEvent, 5: } from 'src/services/analytics/index.js' 6: import { 7: ALL_OAUTH_SCOPES, 8: CLAUDE_AI_INFERENCE_SCOPE, 9: CLAUDE_AI_OAUTH_SCOPES, 10: getOauthConfig, 11: } from '../../constants/oauth.js' 12: import { 13: checkAndRefreshOAuthTokenIfNeeded, 14: getClaudeAIOAuthTokens, 15: hasProfileScope, 16: isClaudeAISubscriber, 17: saveApiKey, 18: } from '../../utils/auth.js' 19: import type { AccountInfo } from '../../utils/config.js' 20: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 21: import { logForDebugging } from '../../utils/debug.js' 22: import { getOauthProfileFromOauthToken } from './getOauthProfile.js' 23: import type { 24: BillingType, 25: OAuthProfileResponse, 26: OAuthTokenExchangeResponse, 27: OAuthTokens, 28: RateLimitTier, 29: SubscriptionType, 30: UserRolesResponse, 31: } from './types.js' 32: export function shouldUseClaudeAIAuth(scopes: string[] | undefined): boolean { 33: return Boolean(scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE)) 34: } 35: export function parseScopes(scopeString?: string): string[] { 36: return scopeString?.split(' ').filter(Boolean) ?? [] 37: } 38: export function buildAuthUrl({ 39: codeChallenge, 40: state, 41: port, 42: isManual, 43: loginWithClaudeAi, 44: inferenceOnly, 45: orgUUID, 46: loginHint, 47: loginMethod, 48: }: { 49: codeChallenge: string 50: state: string 51: port: number 52: isManual: boolean 53: loginWithClaudeAi?: boolean 54: inferenceOnly?: boolean 55: orgUUID?: string 56: loginHint?: string 57: loginMethod?: string 58: }): string { 59: const authUrlBase = loginWithClaudeAi 60: ? getOauthConfig().CLAUDE_AI_AUTHORIZE_URL 61: : getOauthConfig().CONSOLE_AUTHORIZE_URL 62: const authUrl = new URL(authUrlBase) 63: authUrl.searchParams.append('code', 'true') 64: authUrl.searchParams.append('client_id', getOauthConfig().CLIENT_ID) 65: authUrl.searchParams.append('response_type', 'code') 66: authUrl.searchParams.append( 67: 'redirect_uri', 68: isManual 69: ? getOauthConfig().MANUAL_REDIRECT_URL 70: : `http://localhost:${port}/callback`, 71: ) 72: const scopesToUse = inferenceOnly 73: ? [CLAUDE_AI_INFERENCE_SCOPE] 74: : ALL_OAUTH_SCOPES 75: authUrl.searchParams.append('scope', scopesToUse.join(' ')) 76: authUrl.searchParams.append('code_challenge', codeChallenge) 77: authUrl.searchParams.append('code_challenge_method', 'S256') 78: authUrl.searchParams.append('state', state) 79: if (orgUUID) { 80: authUrl.searchParams.append('orgUUID', orgUUID) 81: } 82: if (loginHint) { 83: authUrl.searchParams.append('login_hint', loginHint) 84: } 85: if (loginMethod) { 86: authUrl.searchParams.append('login_method', loginMethod) 87: } 88: return authUrl.toString() 89: } 90: export async function exchangeCodeForTokens( 91: authorizationCode: string, 92: state: string, 93: codeVerifier: string, 94: port: number, 95: useManualRedirect: boolean = false, 96: expiresIn?: number, 97: ): Promise<OAuthTokenExchangeResponse> { 98: const requestBody: Record<string, string | number> = { 99: grant_type: 'authorization_code', 100: code: authorizationCode, 101: redirect_uri: useManualRedirect 102: ? getOauthConfig().MANUAL_REDIRECT_URL 103: : `http://localhost:${port}/callback`, 104: client_id: getOauthConfig().CLIENT_ID, 105: code_verifier: codeVerifier, 106: state, 107: } 108: if (expiresIn !== undefined) { 109: requestBody.expires_in = expiresIn 110: } 111: const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { 112: headers: { 'Content-Type': 'application/json' }, 113: timeout: 15000, 114: }) 115: if (response.status !== 200) { 116: throw new Error( 117: response.status === 401 118: ? 'Authentication failed: Invalid authorization code' 119: : `Token exchange failed (${response.status}): ${response.statusText}`, 120: ) 121: } 122: logEvent('tengu_oauth_token_exchange_success', {}) 123: return response.data 124: } 125: export async function refreshOAuthToken( 126: refreshToken: string, 127: { scopes: requestedScopes }: { scopes?: string[] } = {}, 128: ): Promise<OAuthTokens> { 129: const requestBody = { 130: grant_type: 'refresh_token', 131: refresh_token: refreshToken, 132: client_id: getOauthConfig().CLIENT_ID, 133: scope: (requestedScopes?.length 134: ? requestedScopes 135: : CLAUDE_AI_OAUTH_SCOPES 136: ).join(' '), 137: } 138: try { 139: const response = await axios.post(getOauthConfig().TOKEN_URL, requestBody, { 140: headers: { 'Content-Type': 'application/json' }, 141: timeout: 15000, 142: }) 143: if (response.status !== 200) { 144: throw new Error(`Token refresh failed: ${response.statusText}`) 145: } 146: const data = response.data as OAuthTokenExchangeResponse 147: const { 148: access_token: accessToken, 149: refresh_token: newRefreshToken = refreshToken, 150: expires_in: expiresIn, 151: } = data 152: const expiresAt = Date.now() + expiresIn * 1000 153: const scopes = parseScopes(data.scope) 154: logEvent('tengu_oauth_token_refresh_success', {}) 155: const config = getGlobalConfig() 156: const existing = getClaudeAIOAuthTokens() 157: const haveProfileAlready = 158: config.oauthAccount?.billingType !== undefined && 159: config.oauthAccount?.accountCreatedAt !== undefined && 160: config.oauthAccount?.subscriptionCreatedAt !== undefined && 161: existing?.subscriptionType != null && 162: existing?.rateLimitTier != null 163: const profileInfo = haveProfileAlready 164: ? null 165: : await fetchProfileInfo(accessToken) 166: if (profileInfo && config.oauthAccount) { 167: const updates: Partial<AccountInfo> = {} 168: if (profileInfo.displayName !== undefined) { 169: updates.displayName = profileInfo.displayName 170: } 171: if (typeof profileInfo.hasExtraUsageEnabled === 'boolean') { 172: updates.hasExtraUsageEnabled = profileInfo.hasExtraUsageEnabled 173: } 174: if (profileInfo.billingType !== null) { 175: updates.billingType = profileInfo.billingType 176: } 177: if (profileInfo.accountCreatedAt !== undefined) { 178: updates.accountCreatedAt = profileInfo.accountCreatedAt 179: } 180: if (profileInfo.subscriptionCreatedAt !== undefined) { 181: updates.subscriptionCreatedAt = profileInfo.subscriptionCreatedAt 182: } 183: if (Object.keys(updates).length > 0) { 184: saveGlobalConfig(current => ({ 185: ...current, 186: oauthAccount: current.oauthAccount 187: ? { ...current.oauthAccount, ...updates } 188: : current.oauthAccount, 189: })) 190: } 191: } 192: return { 193: accessToken, 194: refreshToken: newRefreshToken, 195: expiresAt, 196: scopes, 197: subscriptionType: 198: profileInfo?.subscriptionType ?? existing?.subscriptionType ?? null, 199: rateLimitTier: 200: profileInfo?.rateLimitTier ?? existing?.rateLimitTier ?? null, 201: profile: profileInfo?.rawProfile, 202: tokenAccount: data.account 203: ? { 204: uuid: data.account.uuid, 205: emailAddress: data.account.email_address, 206: organizationUuid: data.organization?.uuid, 207: } 208: : undefined, 209: } 210: } catch (error) { 211: const responseBody = 212: axios.isAxiosError(error) && error.response?.data 213: ? JSON.stringify(error.response.data) 214: : undefined 215: logEvent('tengu_oauth_token_refresh_failure', { 216: error: (error as Error) 217: .message as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 218: ...(responseBody && { 219: responseBody: 220: responseBody as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 221: }), 222: }) 223: throw error 224: } 225: } 226: export async function fetchAndStoreUserRoles( 227: accessToken: string, 228: ): Promise<void> { 229: const response = await axios.get(getOauthConfig().ROLES_URL, { 230: headers: { Authorization: `Bearer ${accessToken}` }, 231: }) 232: if (response.status !== 200) { 233: throw new Error(`Failed to fetch user roles: ${response.statusText}`) 234: } 235: const data = response.data as UserRolesResponse 236: const config = getGlobalConfig() 237: if (!config.oauthAccount) { 238: throw new Error('OAuth account information not found in config') 239: } 240: saveGlobalConfig(current => ({ 241: ...current, 242: oauthAccount: current.oauthAccount 243: ? { 244: ...current.oauthAccount, 245: organizationRole: data.organization_role, 246: workspaceRole: data.workspace_role, 247: organizationName: data.organization_name, 248: } 249: : current.oauthAccount, 250: })) 251: logEvent('tengu_oauth_roles_stored', { 252: org_role: 253: data.organization_role as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 254: }) 255: } 256: export async function createAndStoreApiKey( 257: accessToken: string, 258: ): Promise<string | null> { 259: try { 260: const response = await axios.post(getOauthConfig().API_KEY_URL, null, { 261: headers: { Authorization: `Bearer ${accessToken}` }, 262: }) 263: const apiKey = response.data?.raw_key 264: if (apiKey) { 265: await saveApiKey(apiKey) 266: logEvent('tengu_oauth_api_key', { 267: status: 268: 'success' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 269: statusCode: response.status, 270: }) 271: return apiKey 272: } 273: return null 274: } catch (error) { 275: logEvent('tengu_oauth_api_key', { 276: status: 277: 'failure' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 278: error: (error instanceof Error 279: ? error.message 280: : String( 281: error, 282: )) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 283: }) 284: throw error 285: } 286: } 287: export function isOAuthTokenExpired(expiresAt: number | null): boolean { 288: if (expiresAt === null) { 289: return false 290: } 291: const bufferTime = 5 * 60 * 1000 292: const now = Date.now() 293: const expiresWithBuffer = now + bufferTime 294: return expiresWithBuffer >= expiresAt 295: } 296: export async function fetchProfileInfo(accessToken: string): Promise<{ 297: subscriptionType: SubscriptionType | null 298: displayName?: string 299: rateLimitTier: RateLimitTier | null 300: hasExtraUsageEnabled: boolean | null 301: billingType: BillingType | null 302: accountCreatedAt?: string 303: subscriptionCreatedAt?: string 304: rawProfile?: OAuthProfileResponse 305: }> { 306: const profile = await getOauthProfileFromOauthToken(accessToken) 307: const orgType = profile?.organization?.organization_type 308: let subscriptionType: SubscriptionType | null = null 309: switch (orgType) { 310: case 'claude_max': 311: subscriptionType = 'max' 312: break 313: case 'claude_pro': 314: subscriptionType = 'pro' 315: break 316: case 'claude_enterprise': 317: subscriptionType = 'enterprise' 318: break 319: case 'claude_team': 320: subscriptionType = 'team' 321: break 322: default: 323: subscriptionType = null 324: break 325: } 326: const result: { 327: subscriptionType: SubscriptionType | null 328: displayName?: string 329: rateLimitTier: RateLimitTier | null 330: hasExtraUsageEnabled: boolean | null 331: billingType: BillingType | null 332: accountCreatedAt?: string 333: subscriptionCreatedAt?: string 334: } = { 335: subscriptionType, 336: rateLimitTier: profile?.organization?.rate_limit_tier ?? null, 337: hasExtraUsageEnabled: 338: profile?.organization?.has_extra_usage_enabled ?? null, 339: billingType: profile?.organization?.billing_type ?? null, 340: } 341: if (profile?.account?.display_name) { 342: result.displayName = profile.account.display_name 343: } 344: if (profile?.account?.created_at) { 345: result.accountCreatedAt = profile.account.created_at 346: } 347: if (profile?.organization?.subscription_created_at) { 348: result.subscriptionCreatedAt = profile.organization.subscription_created_at 349: } 350: logEvent('tengu_oauth_profile_fetch_success', {}) 351: return { ...result, rawProfile: profile } 352: } 353: export async function getOrganizationUUID(): Promise<string | null> { 354: const globalConfig = getGlobalConfig() 355: const orgUUID = globalConfig.oauthAccount?.organizationUuid 356: if (orgUUID) { 357: return orgUUID 358: } 359: const accessToken = getClaudeAIOAuthTokens()?.accessToken 360: if (accessToken === undefined || !hasProfileScope()) { 361: return null 362: } 363: const profile = await getOauthProfileFromOauthToken(accessToken) 364: const profileOrgUUID = profile?.organization?.uuid 365: if (!profileOrgUUID) { 366: return null 367: } 368: return profileOrgUUID 369: } 370: export async function populateOAuthAccountInfoIfNeeded(): Promise<boolean> { 371: const envAccountUuid = process.env.CLAUDE_CODE_ACCOUNT_UUID 372: const envUserEmail = process.env.CLAUDE_CODE_USER_EMAIL 373: const envOrganizationUuid = process.env.CLAUDE_CODE_ORGANIZATION_UUID 374: const hasEnvVars = Boolean( 375: envAccountUuid && envUserEmail && envOrganizationUuid, 376: ) 377: if (envAccountUuid && envUserEmail && envOrganizationUuid) { 378: if (!getGlobalConfig().oauthAccount) { 379: storeOAuthAccountInfo({ 380: accountUuid: envAccountUuid, 381: emailAddress: envUserEmail, 382: organizationUuid: envOrganizationUuid, 383: }) 384: } 385: } 386: await checkAndRefreshOAuthTokenIfNeeded() 387: const config = getGlobalConfig() 388: if ( 389: (config.oauthAccount && 390: config.oauthAccount.billingType !== undefined && 391: config.oauthAccount.accountCreatedAt !== undefined && 392: config.oauthAccount.subscriptionCreatedAt !== undefined) || 393: !isClaudeAISubscriber() || 394: !hasProfileScope() 395: ) { 396: return false 397: } 398: const tokens = getClaudeAIOAuthTokens() 399: if (tokens?.accessToken) { 400: const profile = await getOauthProfileFromOauthToken(tokens.accessToken) 401: if (profile) { 402: if (hasEnvVars) { 403: logForDebugging( 404: 'OAuth profile fetch succeeded, overriding env var account info', 405: { level: 'info' }, 406: ) 407: } 408: storeOAuthAccountInfo({ 409: accountUuid: profile.account.uuid, 410: emailAddress: profile.account.email, 411: organizationUuid: profile.organization.uuid, 412: displayName: profile.account.display_name || undefined, 413: hasExtraUsageEnabled: 414: profile.organization.has_extra_usage_enabled ?? false, 415: billingType: profile.organization.billing_type ?? undefined, 416: accountCreatedAt: profile.account.created_at, 417: subscriptionCreatedAt: 418: profile.organization.subscription_created_at ?? undefined, 419: }) 420: return true 421: } 422: } 423: return false 424: } 425: export function storeOAuthAccountInfo({ 426: accountUuid, 427: emailAddress, 428: organizationUuid, 429: displayName, 430: hasExtraUsageEnabled, 431: billingType, 432: accountCreatedAt, 433: subscriptionCreatedAt, 434: }: { 435: accountUuid: string 436: emailAddress: string 437: organizationUuid: string | undefined 438: displayName?: string 439: hasExtraUsageEnabled?: boolean 440: billingType?: BillingType 441: accountCreatedAt?: string 442: subscriptionCreatedAt?: string 443: }): void { 444: const accountInfo: AccountInfo = { 445: accountUuid, 446: emailAddress, 447: organizationUuid, 448: hasExtraUsageEnabled, 449: billingType, 450: accountCreatedAt, 451: subscriptionCreatedAt, 452: } 453: if (displayName) { 454: accountInfo.displayName = displayName 455: } 456: saveGlobalConfig(current => { 457: if ( 458: current.oauthAccount?.accountUuid === accountInfo.accountUuid && 459: current.oauthAccount?.emailAddress === accountInfo.emailAddress && 460: current.oauthAccount?.organizationUuid === accountInfo.organizationUuid && 461: current.oauthAccount?.displayName === accountInfo.displayName && 462: current.oauthAccount?.hasExtraUsageEnabled === 463: accountInfo.hasExtraUsageEnabled && 464: current.oauthAccount?.billingType === accountInfo.billingType && 465: current.oauthAccount?.accountCreatedAt === accountInfo.accountCreatedAt && 466: current.oauthAccount?.subscriptionCreatedAt === 467: accountInfo.subscriptionCreatedAt 468: ) { 469: return current 470: } 471: return { ...current, oauthAccount: accountInfo } 472: }) 473: }

File: src/services/oauth/crypto.ts

typescript 1: import { createHash, randomBytes } from 'crypto' 2: function base64URLEncode(buffer: Buffer): string { 3: return buffer 4: .toString('base64') 5: .replace(/\+/g, '-') 6: .replace(/\//g, '_') 7: .replace(/=/g, '') 8: } 9: export function generateCodeVerifier(): string { 10: return base64URLEncode(randomBytes(32)) 11: } 12: export function generateCodeChallenge(verifier: string): string { 13: const hash = createHash('sha256') 14: hash.update(verifier) 15: return base64URLEncode(hash.digest()) 16: } 17: export function generateState(): string { 18: return base64URLEncode(randomBytes(32)) 19: }

File: src/services/oauth/getOauthProfile.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig, OAUTH_BETA_HEADER } from 'src/constants/oauth.js' 3: import type { OAuthProfileResponse } from 'src/services/oauth/types.js' 4: import { getAnthropicApiKey } from 'src/utils/auth.js' 5: import { getGlobalConfig } from 'src/utils/config.js' 6: import { logError } from 'src/utils/log.js' 7: export async function getOauthProfileFromApiKey(): Promise< 8: OAuthProfileResponse | undefined 9: > { 10: const config = getGlobalConfig() 11: const accountUuid = config.oauthAccount?.accountUuid 12: const apiKey = getAnthropicApiKey() 13: if (!accountUuid || !apiKey) { 14: return 15: } 16: const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_cli_profile` 17: try { 18: const response = await axios.get<OAuthProfileResponse>(endpoint, { 19: headers: { 20: 'x-api-key': apiKey, 21: 'anthropic-beta': OAUTH_BETA_HEADER, 22: }, 23: params: { 24: account_uuid: accountUuid, 25: }, 26: timeout: 10000, 27: }) 28: return response.data 29: } catch (error) { 30: logError(error as Error) 31: } 32: } 33: export async function getOauthProfileFromOauthToken( 34: accessToken: string, 35: ): Promise<OAuthProfileResponse | undefined> { 36: const endpoint = `${getOauthConfig().BASE_API_URL}/api/oauth/profile` 37: try { 38: const response = await axios.get<OAuthProfileResponse>(endpoint, { 39: headers: { 40: Authorization: `Bearer ${accessToken}`, 41: 'Content-Type': 'application/json', 42: }, 43: timeout: 10000, 44: }) 45: return response.data 46: } catch (error) { 47: logError(error as Error) 48: } 49: }

File: src/services/oauth/index.ts

typescript 1: import { logEvent } from 'src/services/analytics/index.js' 2: import { openBrowser } from '../../utils/browser.js' 3: import { AuthCodeListener } from './auth-code-listener.js' 4: import * as client from './client.js' 5: import * as crypto from './crypto.js' 6: import type { 7: OAuthProfileResponse, 8: OAuthTokenExchangeResponse, 9: OAuthTokens, 10: RateLimitTier, 11: SubscriptionType, 12: } from './types.js' 13: export class OAuthService { 14: private codeVerifier: string 15: private authCodeListener: AuthCodeListener | null = null 16: private port: number | null = null 17: private manualAuthCodeResolver: ((authorizationCode: string) => void) | null = 18: null 19: constructor() { 20: this.codeVerifier = crypto.generateCodeVerifier() 21: } 22: async startOAuthFlow( 23: authURLHandler: (url: string, automaticUrl?: string) => Promise<void>, 24: options?: { 25: loginWithClaudeAi?: boolean 26: inferenceOnly?: boolean 27: expiresIn?: number 28: orgUUID?: string 29: loginHint?: string 30: loginMethod?: string 31: skipBrowserOpen?: boolean 32: }, 33: ): Promise<OAuthTokens> { 34: this.authCodeListener = new AuthCodeListener() 35: this.port = await this.authCodeListener.start() 36: const codeChallenge = crypto.generateCodeChallenge(this.codeVerifier) 37: const state = crypto.generateState() 38: const opts = { 39: codeChallenge, 40: state, 41: port: this.port, 42: loginWithClaudeAi: options?.loginWithClaudeAi, 43: inferenceOnly: options?.inferenceOnly, 44: orgUUID: options?.orgUUID, 45: loginHint: options?.loginHint, 46: loginMethod: options?.loginMethod, 47: } 48: const manualFlowUrl = client.buildAuthUrl({ ...opts, isManual: true }) 49: const automaticFlowUrl = client.buildAuthUrl({ ...opts, isManual: false }) 50: const authorizationCode = await this.waitForAuthorizationCode( 51: state, 52: async () => { 53: if (options?.skipBrowserOpen) { 54: await authURLHandler(manualFlowUrl, automaticFlowUrl) 55: } else { 56: await authURLHandler(manualFlowUrl) 57: await openBrowser(automaticFlowUrl) 58: } 59: }, 60: ) 61: const isAutomaticFlow = this.authCodeListener?.hasPendingResponse() ?? false 62: logEvent('tengu_oauth_auth_code_received', { automatic: isAutomaticFlow }) 63: try { 64: const tokenResponse = await client.exchangeCodeForTokens( 65: authorizationCode, 66: state, 67: this.codeVerifier, 68: this.port!, 69: !isAutomaticFlow, 70: options?.expiresIn, 71: ) 72: const profileInfo = await client.fetchProfileInfo( 73: tokenResponse.access_token, 74: ) 75: if (isAutomaticFlow) { 76: const scopes = client.parseScopes(tokenResponse.scope) 77: this.authCodeListener?.handleSuccessRedirect(scopes) 78: } 79: return this.formatTokens( 80: tokenResponse, 81: profileInfo.subscriptionType, 82: profileInfo.rateLimitTier, 83: profileInfo.rawProfile, 84: ) 85: } catch (error) { 86: if (isAutomaticFlow) { 87: this.authCodeListener?.handleErrorRedirect() 88: } 89: throw error 90: } finally { 91: this.authCodeListener?.close() 92: } 93: } 94: private async waitForAuthorizationCode( 95: state: string, 96: onReady: () => Promise<void>, 97: ): Promise<string> { 98: return new Promise((resolve, reject) => { 99: this.manualAuthCodeResolver = resolve 100: this.authCodeListener 101: ?.waitForAuthorization(state, onReady) 102: .then(authorizationCode => { 103: this.manualAuthCodeResolver = null 104: resolve(authorizationCode) 105: }) 106: .catch(error => { 107: this.manualAuthCodeResolver = null 108: reject(error) 109: }) 110: }) 111: } 112: handleManualAuthCodeInput(params: { 113: authorizationCode: string 114: state: string 115: }): void { 116: if (this.manualAuthCodeResolver) { 117: this.manualAuthCodeResolver(params.authorizationCode) 118: this.manualAuthCodeResolver = null 119: this.authCodeListener?.close() 120: } 121: } 122: private formatTokens( 123: response: OAuthTokenExchangeResponse, 124: subscriptionType: SubscriptionType | null, 125: rateLimitTier: RateLimitTier | null, 126: profile?: OAuthProfileResponse, 127: ): OAuthTokens { 128: return { 129: accessToken: response.access_token, 130: refreshToken: response.refresh_token, 131: expiresAt: Date.now() + response.expires_in * 1000, 132: scopes: client.parseScopes(response.scope), 133: subscriptionType, 134: rateLimitTier, 135: profile, 136: tokenAccount: response.account 137: ? { 138: uuid: response.account.uuid, 139: emailAddress: response.account.email_address, 140: organizationUuid: response.organization?.uuid, 141: } 142: : undefined, 143: } 144: } 145: cleanup(): void { 146: this.authCodeListener?.close() 147: this.manualAuthCodeResolver = null 148: } 149: }

File: src/services/plugins/pluginCliCommands.ts

typescript 1: import figures from 'figures' 2: import { errorMessage } from '../../utils/errors.js' 3: import { gracefulShutdown } from '../../utils/gracefulShutdown.js' 4: import { logError } from '../../utils/log.js' 5: import { getManagedPluginNames } from '../../utils/plugins/managedPlugins.js' 6: import { parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js' 7: import type { PluginScope } from '../../utils/plugins/schemas.js' 8: import { writeToStdout } from '../../utils/process.js' 9: import { 10: buildPluginTelemetryFields, 11: classifyPluginCommandError, 12: } from '../../utils/telemetry/pluginTelemetry.js' 13: import { 14: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 15: type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 16: logEvent, 17: } from '../analytics/index.js' 18: import { 19: disableAllPluginsOp, 20: disablePluginOp, 21: enablePluginOp, 22: type InstallableScope, 23: installPluginOp, 24: uninstallPluginOp, 25: updatePluginOp, 26: VALID_INSTALLABLE_SCOPES, 27: VALID_UPDATE_SCOPES, 28: } from './pluginOperations.js' 29: export { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } 30: type PluginCliCommand = 31: | 'install' 32: | 'uninstall' 33: | 'enable' 34: | 'disable' 35: | 'disable-all' 36: | 'update' 37: function handlePluginCommandError( 38: error: unknown, 39: command: PluginCliCommand, 40: plugin?: string, 41: ): never { 42: logError(error) 43: const operation = plugin 44: ? `${command} plugin "${plugin}"` 45: : command === 'disable-all' 46: ? 'disable all plugins' 47: : `${command} plugins` 48: console.error( 49: `${figures.cross} Failed to ${operation}: ${errorMessage(error)}`, 50: ) 51: const telemetryFields = plugin 52: ? (() => { 53: const { name, marketplace } = parsePluginIdentifier(plugin) 54: return { 55: _PROTO_plugin_name: 56: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 57: ...(marketplace && { 58: _PROTO_marketplace_name: 59: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 60: }), 61: ...buildPluginTelemetryFields( 62: name, 63: marketplace, 64: getManagedPluginNames(), 65: ), 66: } 67: })() 68: : {} 69: logEvent('tengu_plugin_command_failed', { 70: command: 71: command as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 72: error_category: classifyPluginCommandError( 73: error, 74: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 75: ...telemetryFields, 76: }) 77: process.exit(1) 78: } 79: export async function installPlugin( 80: plugin: string, 81: scope: InstallableScope = 'user', 82: ): Promise<void> { 83: try { 84: console.log(`Installing plugin "${plugin}"...`) 85: const result = await installPluginOp(plugin, scope) 86: if (!result.success) { 87: throw new Error(result.message) 88: } 89: console.log(`${figures.tick} ${result.message}`) 90: const { name, marketplace } = parsePluginIdentifier( 91: result.pluginId || plugin, 92: ) 93: logEvent('tengu_plugin_installed_cli', { 94: _PROTO_plugin_name: 95: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 96: ...(marketplace && { 97: _PROTO_marketplace_name: 98: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 99: }), 100: scope: (result.scope || 101: scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 102: install_source: 103: 'cli-explicit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 104: ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), 105: }) 106: process.exit(0) 107: } catch (error) { 108: handlePluginCommandError(error, 'install', plugin) 109: } 110: } 111: export async function uninstallPlugin( 112: plugin: string, 113: scope: InstallableScope = 'user', 114: keepData = false, 115: ): Promise<void> { 116: try { 117: const result = await uninstallPluginOp(plugin, scope, !keepData) 118: if (!result.success) { 119: throw new Error(result.message) 120: } 121: console.log(`${figures.tick} ${result.message}`) 122: const { name, marketplace } = parsePluginIdentifier( 123: result.pluginId || plugin, 124: ) 125: logEvent('tengu_plugin_uninstalled_cli', { 126: _PROTO_plugin_name: 127: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 128: ...(marketplace && { 129: _PROTO_marketplace_name: 130: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 131: }), 132: scope: (result.scope || 133: scope) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 134: ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), 135: }) 136: process.exit(0) 137: } catch (error) { 138: handlePluginCommandError(error, 'uninstall', plugin) 139: } 140: } 141: export async function enablePlugin( 142: plugin: string, 143: scope?: InstallableScope, 144: ): Promise<void> { 145: try { 146: const result = await enablePluginOp(plugin, scope) 147: if (!result.success) { 148: throw new Error(result.message) 149: } 150: console.log(`${figures.tick} ${result.message}`) 151: const { name, marketplace } = parsePluginIdentifier( 152: result.pluginId || plugin, 153: ) 154: logEvent('tengu_plugin_enabled_cli', { 155: _PROTO_plugin_name: 156: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 157: ...(marketplace && { 158: _PROTO_marketplace_name: 159: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 160: }), 161: scope: 162: result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 163: ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), 164: }) 165: process.exit(0) 166: } catch (error) { 167: handlePluginCommandError(error, 'enable', plugin) 168: } 169: } 170: export async function disablePlugin( 171: plugin: string, 172: scope?: InstallableScope, 173: ): Promise<void> { 174: try { 175: const result = await disablePluginOp(plugin, scope) 176: if (!result.success) { 177: throw new Error(result.message) 178: } 179: console.log(`${figures.tick} ${result.message}`) 180: const { name, marketplace } = parsePluginIdentifier( 181: result.pluginId || plugin, 182: ) 183: logEvent('tengu_plugin_disabled_cli', { 184: _PROTO_plugin_name: 185: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 186: ...(marketplace && { 187: _PROTO_marketplace_name: 188: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 189: }), 190: scope: 191: result.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 192: ...buildPluginTelemetryFields(name, marketplace, getManagedPluginNames()), 193: }) 194: process.exit(0) 195: } catch (error) { 196: handlePluginCommandError(error, 'disable', plugin) 197: } 198: } 199: export async function disableAllPlugins(): Promise<void> { 200: try { 201: const result = await disableAllPluginsOp() 202: if (!result.success) { 203: throw new Error(result.message) 204: } 205: console.log(`${figures.tick} ${result.message}`) 206: logEvent('tengu_plugin_disabled_all_cli', {}) 207: process.exit(0) 208: } catch (error) { 209: handlePluginCommandError(error, 'disable-all') 210: } 211: } 212: export async function updatePluginCli( 213: plugin: string, 214: scope: PluginScope, 215: ): Promise<void> { 216: try { 217: writeToStdout( 218: `Checking for updates for plugin "${plugin}" at ${scope} scope…\n`, 219: ) 220: const result = await updatePluginOp(plugin, scope) 221: if (!result.success) { 222: throw new Error(result.message) 223: } 224: writeToStdout(`${figures.tick} ${result.message}\n`) 225: if (!result.alreadyUpToDate) { 226: const { name, marketplace } = parsePluginIdentifier( 227: result.pluginId || plugin, 228: ) 229: logEvent('tengu_plugin_updated_cli', { 230: _PROTO_plugin_name: 231: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 232: ...(marketplace && { 233: _PROTO_marketplace_name: 234: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 235: }), 236: old_version: (result.oldVersion || 237: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 238: new_version: (result.newVersion || 239: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 240: ...buildPluginTelemetryFields( 241: name, 242: marketplace, 243: getManagedPluginNames(), 244: ), 245: }) 246: } 247: await gracefulShutdown(0) 248: } catch (error) { 249: handlePluginCommandError(error, 'update', plugin) 250: } 251: }

File: src/services/plugins/PluginInstallationManager.ts

typescript 1: import type { AppState } from '../../state/AppState.js' 2: import { logForDebugging } from '../../utils/debug.js' 3: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 4: import { logError } from '../../utils/log.js' 5: import { 6: clearMarketplacesCache, 7: getDeclaredMarketplaces, 8: loadKnownMarketplacesConfig, 9: } from '../../utils/plugins/marketplaceManager.js' 10: import { clearPluginCache } from '../../utils/plugins/pluginLoader.js' 11: import { 12: diffMarketplaces, 13: reconcileMarketplaces, 14: } from '../../utils/plugins/reconciler.js' 15: import { refreshActivePlugins } from '../../utils/plugins/refresh.js' 16: import { logEvent } from '../analytics/index.js' 17: type SetAppState = (f: (prevState: AppState) => AppState) => void 18: function updateMarketplaceStatus( 19: setAppState: SetAppState, 20: name: string, 21: status: 'pending' | 'installing' | 'installed' | 'failed', 22: error?: string, 23: ): void { 24: setAppState(prevState => ({ 25: ...prevState, 26: plugins: { 27: ...prevState.plugins, 28: installationStatus: { 29: ...prevState.plugins.installationStatus, 30: marketplaces: prevState.plugins.installationStatus.marketplaces.map( 31: m => (m.name === name ? { ...m, status, error } : m), 32: ), 33: }, 34: }, 35: })) 36: } 37: export async function performBackgroundPluginInstallations( 38: setAppState: SetAppState, 39: ): Promise<void> { 40: logForDebugging('performBackgroundPluginInstallations called') 41: try { 42: const declared = getDeclaredMarketplaces() 43: const materialized = await loadKnownMarketplacesConfig().catch(() => ({})) 44: const diff = diffMarketplaces(declared, materialized) 45: const pendingNames = [ 46: ...diff.missing, 47: ...diff.sourceChanged.map(c => c.name), 48: ] 49: setAppState(prev => ({ 50: ...prev, 51: plugins: { 52: ...prev.plugins, 53: installationStatus: { 54: marketplaces: pendingNames.map(name => ({ 55: name, 56: status: 'pending' as const, 57: })), 58: plugins: [], 59: }, 60: }, 61: })) 62: if (pendingNames.length === 0) { 63: return 64: } 65: logForDebugging( 66: `Installing ${pendingNames.length} marketplace(s) in background`, 67: ) 68: const result = await reconcileMarketplaces({ 69: onProgress: event => { 70: switch (event.type) { 71: case 'installing': 72: updateMarketplaceStatus(setAppState, event.name, 'installing') 73: break 74: case 'installed': 75: updateMarketplaceStatus(setAppState, event.name, 'installed') 76: break 77: case 'failed': 78: updateMarketplaceStatus( 79: setAppState, 80: event.name, 81: 'failed', 82: event.error, 83: ) 84: break 85: } 86: }, 87: }) 88: const metrics = { 89: installed_count: result.installed.length, 90: updated_count: result.updated.length, 91: failed_count: result.failed.length, 92: up_to_date_count: result.upToDate.length, 93: } 94: logEvent('tengu_marketplace_background_install', metrics) 95: logForDiagnosticsNoPII( 96: 'info', 97: 'tengu_marketplace_background_install', 98: metrics, 99: ) 100: if (result.installed.length > 0) { 101: clearMarketplacesCache() 102: logForDebugging( 103: `Auto-refreshing plugins after ${result.installed.length} new marketplace(s) installed`, 104: ) 105: try { 106: await refreshActivePlugins(setAppState) 107: } catch (refreshError) { 108: logError(refreshError) 109: logForDebugging( 110: `Auto-refresh failed, falling back to needsRefresh: ${refreshError}`, 111: { level: 'warn' }, 112: ) 113: clearPluginCache( 114: 'performBackgroundPluginInstallations: auto-refresh failed', 115: ) 116: setAppState(prev => { 117: if (prev.plugins.needsRefresh) return prev 118: return { 119: ...prev, 120: plugins: { ...prev.plugins, needsRefresh: true }, 121: } 122: }) 123: } 124: } else if (result.updated.length > 0) { 125: clearMarketplacesCache() 126: clearPluginCache( 127: 'performBackgroundPluginInstallations: marketplaces reconciled', 128: ) 129: setAppState(prev => { 130: if (prev.plugins.needsRefresh) return prev 131: return { 132: ...prev, 133: plugins: { ...prev.plugins, needsRefresh: true }, 134: } 135: }) 136: } 137: } catch (error) { 138: logError(error) 139: } 140: }

File: src/services/plugins/pluginOperations.ts

typescript 1: import { dirname, join } from 'path' 2: import { getOriginalCwd } from '../../bootstrap/state.js' 3: import { isBuiltinPluginId } from '../../plugins/builtinPlugins.js' 4: import type { LoadedPlugin, PluginManifest } from '../../types/plugin.js' 5: import { isENOENT, toError } from '../../utils/errors.js' 6: import { getFsImplementation } from '../../utils/fsOperations.js' 7: import { logError } from '../../utils/log.js' 8: import { 9: clearAllCaches, 10: markPluginVersionOrphaned, 11: } from '../../utils/plugins/cacheUtils.js' 12: import { 13: findReverseDependents, 14: formatReverseDependentsSuffix, 15: } from '../../utils/plugins/dependencyResolver.js' 16: import { 17: loadInstalledPluginsFromDisk, 18: loadInstalledPluginsV2, 19: removePluginInstallation, 20: updateInstallationPathOnDisk, 21: } from '../../utils/plugins/installedPluginsManager.js' 22: import { 23: getMarketplace, 24: getPluginById, 25: loadKnownMarketplacesConfig, 26: } from '../../utils/plugins/marketplaceManager.js' 27: import { deletePluginDataDir } from '../../utils/plugins/pluginDirectories.js' 28: import { 29: parsePluginIdentifier, 30: scopeToSettingSource, 31: } from '../../utils/plugins/pluginIdentifier.js' 32: import { 33: formatResolutionError, 34: installResolvedPlugin, 35: } from '../../utils/plugins/pluginInstallationHelpers.js' 36: import { 37: cachePlugin, 38: copyPluginToVersionedCache, 39: getVersionedCachePath, 40: getVersionedZipCachePath, 41: loadAllPlugins, 42: loadPluginManifest, 43: } from '../../utils/plugins/pluginLoader.js' 44: import { deletePluginOptions } from '../../utils/plugins/pluginOptionsStorage.js' 45: import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js' 46: import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js' 47: import { calculatePluginVersion } from '../../utils/plugins/pluginVersioning.js' 48: import type { 49: PluginMarketplaceEntry, 50: PluginScope, 51: } from '../../utils/plugins/schemas.js' 52: import { 53: getSettingsForSource, 54: updateSettingsForSource, 55: } from '../../utils/settings/settings.js' 56: import { plural } from '../../utils/stringUtils.js' 57: export const VALID_INSTALLABLE_SCOPES = ['user', 'project', 'local'] as const 58: export type InstallableScope = (typeof VALID_INSTALLABLE_SCOPES)[number] 59: export const VALID_UPDATE_SCOPES: readonly PluginScope[] = [ 60: 'user', 61: 'project', 62: 'local', 63: 'managed', 64: ] as const 65: export function assertInstallableScope( 66: scope: string, 67: ): asserts scope is InstallableScope { 68: if (!VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope)) { 69: throw new Error( 70: `Invalid scope "${scope}". Must be one of: ${VALID_INSTALLABLE_SCOPES.join(', ')}`, 71: ) 72: } 73: } 74: export function isInstallableScope( 75: scope: PluginScope, 76: ): scope is InstallableScope { 77: return VALID_INSTALLABLE_SCOPES.includes(scope as InstallableScope) 78: } 79: export function getProjectPathForScope(scope: PluginScope): string | undefined { 80: return scope === 'project' || scope === 'local' ? getOriginalCwd() : undefined 81: } 82: export function isPluginEnabledAtProjectScope(pluginId: string): boolean { 83: return ( 84: getSettingsForSource('projectSettings')?.enabledPlugins?.[pluginId] === true 85: ) 86: } 87: export type PluginOperationResult = { 88: success: boolean 89: message: string 90: pluginId?: string 91: pluginName?: string 92: scope?: PluginScope 93: reverseDependents?: string[] 94: } 95: export type PluginUpdateResult = { 96: success: boolean 97: message: string 98: pluginId?: string 99: newVersion?: string 100: oldVersion?: string 101: alreadyUpToDate?: boolean 102: scope?: PluginScope 103: } 104: function findPluginInSettings(plugin: string): { 105: pluginId: string 106: scope: InstallableScope 107: } | null { 108: const hasMarketplace = plugin.includes('@') 109: const searchOrder: InstallableScope[] = ['local', 'project', 'user'] 110: for (const scope of searchOrder) { 111: const enabledPlugins = getSettingsForSource( 112: scopeToSettingSource(scope), 113: )?.enabledPlugins 114: if (!enabledPlugins) continue 115: for (const key of Object.keys(enabledPlugins)) { 116: if (hasMarketplace ? key === plugin : key.startsWith(`${plugin}@`)) { 117: return { pluginId: key, scope } 118: } 119: } 120: } 121: return null 122: } 123: function findPluginByIdentifier( 124: plugin: string, 125: plugins: LoadedPlugin[], 126: ): LoadedPlugin | undefined { 127: const { name, marketplace } = parsePluginIdentifier(plugin) 128: return plugins.find(p => { 129: if (p.name === plugin || p.name === name) return true 130: if (marketplace && p.source) { 131: return p.name === name && p.source.includes(`@${marketplace}`) 132: } 133: return false 134: }) 135: } 136: function resolveDelistedPluginId( 137: plugin: string, 138: ): { pluginId: string; pluginName: string } | null { 139: const { name } = parsePluginIdentifier(plugin) 140: const installedData = loadInstalledPluginsV2() 141: if (installedData.plugins[plugin]?.length) { 142: return { pluginId: plugin, pluginName: name } 143: } 144: const matchingKey = Object.keys(installedData.plugins).find(key => { 145: const { name: keyName } = parsePluginIdentifier(key) 146: return keyName === name && (installedData.plugins[key]?.length ?? 0) > 0 147: }) 148: if (matchingKey) { 149: return { pluginId: matchingKey, pluginName: name } 150: } 151: return null 152: } 153: export function getPluginInstallationFromV2(pluginId: string): { 154: scope: PluginScope 155: projectPath?: string 156: } { 157: const installedData = loadInstalledPluginsV2() 158: const installations = installedData.plugins[pluginId] 159: if (!installations || installations.length === 0) { 160: return { scope: 'user' } 161: } 162: const currentProjectPath = getOriginalCwd() 163: const localInstall = installations.find( 164: inst => inst.scope === 'local' && inst.projectPath === currentProjectPath, 165: ) 166: if (localInstall) { 167: return { scope: localInstall.scope, projectPath: localInstall.projectPath } 168: } 169: const projectInstall = installations.find( 170: inst => inst.scope === 'project' && inst.projectPath === currentProjectPath, 171: ) 172: if (projectInstall) { 173: return { 174: scope: projectInstall.scope, 175: projectPath: projectInstall.projectPath, 176: } 177: } 178: const userInstall = installations.find(inst => inst.scope === 'user') 179: if (userInstall) { 180: return { scope: userInstall.scope } 181: } 182: return { 183: scope: installations[0]!.scope, 184: projectPath: installations[0]!.projectPath, 185: } 186: } 187: export async function installPluginOp( 188: plugin: string, 189: scope: InstallableScope = 'user', 190: ): Promise<PluginOperationResult> { 191: assertInstallableScope(scope) 192: const { name: pluginName, marketplace: marketplaceName } = 193: parsePluginIdentifier(plugin) 194: let foundPlugin: PluginMarketplaceEntry | undefined 195: let foundMarketplace: string | undefined 196: let marketplaceInstallLocation: string | undefined 197: if (marketplaceName) { 198: const pluginInfo = await getPluginById(plugin) 199: if (pluginInfo) { 200: foundPlugin = pluginInfo.entry 201: foundMarketplace = marketplaceName 202: marketplaceInstallLocation = pluginInfo.marketplaceInstallLocation 203: } 204: } else { 205: const marketplaces = await loadKnownMarketplacesConfig() 206: for (const [mktName, mktConfig] of Object.entries(marketplaces)) { 207: try { 208: const marketplace = await getMarketplace(mktName) 209: const pluginEntry = marketplace.plugins.find(p => p.name === pluginName) 210: if (pluginEntry) { 211: foundPlugin = pluginEntry 212: foundMarketplace = mktName 213: marketplaceInstallLocation = mktConfig.installLocation 214: break 215: } 216: } catch (error) { 217: logError(toError(error)) 218: continue 219: } 220: } 221: } 222: if (!foundPlugin || !foundMarketplace) { 223: const location = marketplaceName 224: ? `marketplace "${marketplaceName}"` 225: : 'any configured marketplace' 226: return { 227: success: false, 228: message: `Plugin "${pluginName}" not found in ${location}`, 229: } 230: } 231: const entry = foundPlugin 232: const pluginId = `${entry.name}@${foundMarketplace}` 233: const result = await installResolvedPlugin({ 234: pluginId, 235: entry, 236: scope, 237: marketplaceInstallLocation, 238: }) 239: if (!result.ok) { 240: switch (result.reason) { 241: case 'local-source-no-location': 242: return { 243: success: false, 244: message: `Cannot install local plugin "${result.pluginName}" without marketplace install location`, 245: } 246: case 'settings-write-failed': 247: return { 248: success: false, 249: message: `Failed to update settings: ${result.message}`, 250: } 251: case 'resolution-failed': 252: return { 253: success: false, 254: message: formatResolutionError(result.resolution), 255: } 256: case 'blocked-by-policy': 257: return { 258: success: false, 259: message: `Plugin "${result.pluginName}" is blocked by your organization's policy and cannot be installed`, 260: } 261: case 'dependency-blocked-by-policy': 262: return { 263: success: false, 264: message: `Plugin "${result.pluginName}" depends on "${result.blockedDependency}", which is blocked by your organization's policy`, 265: } 266: } 267: } 268: return { 269: success: true, 270: message: `Successfully installed plugin: ${pluginId} (scope: ${scope})${result.depNote}`, 271: pluginId, 272: pluginName: entry.name, 273: scope, 274: } 275: } 276: export async function uninstallPluginOp( 277: plugin: string, 278: scope: InstallableScope = 'user', 279: deleteDataDir = true, 280: ): Promise<PluginOperationResult> { 281: assertInstallableScope(scope) 282: const { enabled, disabled } = await loadAllPlugins() 283: const allPlugins = [...enabled, ...disabled] 284: const foundPlugin = findPluginByIdentifier(plugin, allPlugins) 285: const settingSource = scopeToSettingSource(scope) 286: const settings = getSettingsForSource(settingSource) 287: let pluginId: string 288: let pluginName: string 289: if (foundPlugin) { 290: pluginId = 291: Object.keys(settings?.enabledPlugins ?? {}).find( 292: k => 293: k === plugin || 294: k === foundPlugin.name || 295: k.startsWith(`${foundPlugin.name}@`), 296: ) ?? (plugin.includes('@') ? plugin : foundPlugin.name) 297: pluginName = foundPlugin.name 298: } else { 299: const resolved = resolveDelistedPluginId(plugin) 300: if (!resolved) { 301: return { 302: success: false, 303: message: `Plugin "${plugin}" not found in installed plugins`, 304: } 305: } 306: pluginId = resolved.pluginId 307: pluginName = resolved.pluginName 308: } 309: const projectPath = getProjectPathForScope(scope) 310: const installedData = loadInstalledPluginsV2() 311: const installations = installedData.plugins[pluginId] 312: const scopeInstallation = installations?.find( 313: i => i.scope === scope && i.projectPath === projectPath, 314: ) 315: if (!scopeInstallation) { 316: const { scope: actualScope } = getPluginInstallationFromV2(pluginId) 317: if (actualScope !== scope && installations && installations.length > 0) { 318: if (actualScope === 'project') { 319: return { 320: success: false, 321: message: `Plugin "${plugin}" is enabled at project scope (.claude/settings.json, shared with your team). To disable just for you: claude plugin disable ${plugin} --scope local`, 322: } 323: } 324: return { 325: success: false, 326: message: `Plugin "${plugin}" is installed in ${actualScope} scope, not ${scope}. Use --scope ${actualScope} to uninstall.`, 327: } 328: } 329: return { 330: success: false, 331: message: `Plugin "${plugin}" is not installed in ${scope} scope. Use --scope to specify the correct scope.`, 332: } 333: } 334: const installPath = scopeInstallation.installPath 335: const newEnabledPlugins: Record<string, boolean | string[] | undefined> = { 336: ...settings?.enabledPlugins, 337: } 338: newEnabledPlugins[pluginId] = undefined 339: updateSettingsForSource(settingSource, { 340: enabledPlugins: newEnabledPlugins, 341: }) 342: clearAllCaches() 343: removePluginInstallation(pluginId, scope, projectPath) 344: const updatedData = loadInstalledPluginsV2() 345: const remainingInstallations = updatedData.plugins[pluginId] 346: const isLastScope = 347: !remainingInstallations || remainingInstallations.length === 0 348: if (isLastScope && installPath) { 349: await markPluginVersionOrphaned(installPath) 350: } 351: if (isLastScope) { 352: deletePluginOptions(pluginId) 353: if (deleteDataDir) { 354: await deletePluginDataDir(pluginId) 355: } 356: } 357: const reverseDependents = findReverseDependents(pluginId, allPlugins) 358: const depWarn = formatReverseDependentsSuffix(reverseDependents) 359: return { 360: success: true, 361: message: `Successfully uninstalled plugin: ${pluginName} (scope: ${scope})${depWarn}`, 362: pluginId, 363: pluginName, 364: scope, 365: reverseDependents: 366: reverseDependents.length > 0 ? reverseDependents : undefined, 367: } 368: } 369: export async function setPluginEnabledOp( 370: plugin: string, 371: enabled: boolean, 372: scope?: InstallableScope, 373: ): Promise<PluginOperationResult> { 374: const operation = enabled ? 'enable' : 'disable' 375: if (isBuiltinPluginId(plugin)) { 376: const { error } = updateSettingsForSource('userSettings', { 377: enabledPlugins: { 378: ...getSettingsForSource('userSettings')?.enabledPlugins, 379: [plugin]: enabled, 380: }, 381: }) 382: if (error) { 383: return { 384: success: false, 385: message: `Failed to ${operation} built-in plugin: ${error.message}`, 386: } 387: } 388: clearAllCaches() 389: const { name: pluginName } = parsePluginIdentifier(plugin) 390: return { 391: success: true, 392: message: `Successfully ${operation}d built-in plugin: ${pluginName}`, 393: pluginId: plugin, 394: pluginName, 395: scope: 'user', 396: } 397: } 398: if (scope) { 399: assertInstallableScope(scope) 400: } 401: let pluginId: string 402: let resolvedScope: InstallableScope 403: const found = findPluginInSettings(plugin) 404: if (scope) { 405: resolvedScope = scope 406: if (found) { 407: pluginId = found.pluginId 408: } else if (plugin.includes('@')) { 409: pluginId = plugin 410: } else { 411: return { 412: success: false, 413: message: `Plugin "${plugin}" not found in settings. Use plugin@marketplace format.`, 414: } 415: } 416: } else if (found) { 417: pluginId = found.pluginId 418: resolvedScope = found.scope 419: } else if (plugin.includes('@')) { 420: pluginId = plugin 421: resolvedScope = 'user' 422: } else { 423: return { 424: success: false, 425: message: `Plugin "${plugin}" not found in any editable settings scope. Use plugin@marketplace format.`, 426: } 427: } 428: if (enabled && isPluginBlockedByPolicy(pluginId)) { 429: return { 430: success: false, 431: message: `Plugin "${pluginId}" is blocked by your organization's policy and cannot be enabled`, 432: } 433: } 434: const settingSource = scopeToSettingSource(resolvedScope) 435: const scopeSettingsValue = 436: getSettingsForSource(settingSource)?.enabledPlugins?.[pluginId] 437: const SCOPE_PRECEDENCE: Record<InstallableScope, number> = { 438: user: 0, 439: project: 1, 440: local: 2, 441: } 442: const isOverride = 443: scope && found && SCOPE_PRECEDENCE[scope] > SCOPE_PRECEDENCE[found.scope] 444: if ( 445: scope && 446: scopeSettingsValue === undefined && 447: found && 448: found.scope !== scope && 449: !isOverride 450: ) { 451: return { 452: success: false, 453: message: `Plugin "${plugin}" is installed at ${found.scope} scope, not ${scope}. Use --scope ${found.scope} or omit --scope to auto-detect.`, 454: } 455: } 456: const isCurrentlyEnabled = 457: scope && !isOverride 458: ? scopeSettingsValue === true 459: : getPluginEditableScopes().has(pluginId) 460: if (enabled === isCurrentlyEnabled) { 461: return { 462: success: false, 463: message: `Plugin "${plugin}" is already ${enabled ? 'enabled' : 'disabled'}${scope ? ` at ${scope} scope` : ''}`, 464: } 465: } 466: // On disable: capture reverse dependents from the PRE-disable snapshot, 467: // before we write settings and clear the memoized plugin cache. 468: let reverseDependents: string[] | undefined 469: if (!enabled) { 470: const { enabled: loadedEnabled, disabled } = await loadAllPlugins() 471: const rdeps = findReverseDependents(pluginId, [ 472: ...loadedEnabled, 473: ...disabled, 474: ]) 475: if (rdeps.length > 0) reverseDependents = rdeps 476: } 477: // ── ACTION: write settings ── 478: const { error } = updateSettingsForSource(settingSource, { 479: enabledPlugins: { 480: ...getSettingsForSource(settingSource)?.enabledPlugins, 481: [pluginId]: enabled, 482: }, 483: }) 484: if (error) { 485: return { 486: success: false, 487: message: `Failed to ${operation} plugin: ${error.message}`, 488: } 489: } 490: clearAllCaches() 491: const { name: pluginName } = parsePluginIdentifier(pluginId) 492: const depWarn = formatReverseDependentsSuffix(reverseDependents) 493: return { 494: success: true, 495: message: `Successfully ${operation}d plugin: ${pluginName} (scope: ${resolvedScope})${depWarn}`, 496: pluginId, 497: pluginName, 498: scope: resolvedScope, 499: reverseDependents, 500: } 501: } 502: /** 503: * Enable a plugin 504: * 505: * @param plugin Plugin name or plugin@marketplace identifier 506: * @param scope Optional scope. If not provided, finds the most specific scope for the current project. 507: * @returns Result indicating success/failure 508: */ 509: export async function enablePluginOp( 510: plugin: string, 511: scope?: InstallableScope, 512: ): Promise<PluginOperationResult> { 513: return setPluginEnabledOp(plugin, true, scope) 514: } 515: /** 516: * Disable a plugin 517: * 518: * @param plugin Plugin name or plugin@marketplace identifier 519: * @param scope Optional scope. If not provided, finds the most specific scope for the current project. 520: * @returns Result indicating success/failure 521: */ 522: export async function disablePluginOp( 523: plugin: string, 524: scope?: InstallableScope, 525: ): Promise<PluginOperationResult> { 526: return setPluginEnabledOp(plugin, false, scope) 527: } 528: /** 529: * Disable all enabled plugins 530: * 531: * @returns Result indicating success/failure with count of disabled plugins 532: */ 533: export async function disableAllPluginsOp(): Promise<PluginOperationResult> { 534: const enabledPlugins = getPluginEditableScopes() 535: if (enabledPlugins.size === 0) { 536: return { success: true, message: 'No enabled plugins to disable' } 537: } 538: const disabled: string[] = [] 539: const errors: string[] = [] 540: for (const [pluginId] of enabledPlugins) { 541: const result = await setPluginEnabledOp(pluginId, false) 542: if (result.success) { 543: disabled.push(pluginId) 544: } else { 545: errors.push(`${pluginId}: ${result.message}`) 546: } 547: } 548: if (errors.length > 0) { 549: return { 550: success: false, 551: message: `Disabled ${disabled.length} ${plural(disabled.length, 'plugin')}, ${errors.length} failed:\n${errors.join('\n')}`, 552: } 553: } 554: return { 555: success: true, 556: message: `Disabled ${disabled.length} ${plural(disabled.length, 'plugin')}`, 557: } 558: } 559: export async function updatePluginOp( 560: plugin: string, 561: scope: PluginScope, 562: ): Promise<PluginUpdateResult> { 563: const { name: pluginName, marketplace: marketplaceName } = 564: parsePluginIdentifier(plugin) 565: const pluginId = marketplaceName ? `${pluginName}@${marketplaceName}` : plugin 566: const pluginInfo = await getPluginById(plugin) 567: if (!pluginInfo) { 568: return { 569: success: false, 570: message: `Plugin "${pluginName}" not found`, 571: pluginId, 572: scope, 573: } 574: } 575: const { entry, marketplaceInstallLocation } = pluginInfo 576: const diskData = loadInstalledPluginsFromDisk() 577: const installations = diskData.plugins[pluginId] 578: if (!installations || installations.length === 0) { 579: return { 580: success: false, 581: message: `Plugin "${pluginName}" is not installed`, 582: pluginId, 583: scope, 584: } 585: } 586: const projectPath = getProjectPathForScope(scope) 587: const installation = installations.find( 588: inst => inst.scope === scope && inst.projectPath === projectPath, 589: ) 590: if (!installation) { 591: const scopeDesc = projectPath ? `${scope} (${projectPath})` : scope 592: return { 593: success: false, 594: message: `Plugin "${pluginName}" is not installed at scope ${scopeDesc}`, 595: pluginId, 596: scope, 597: } 598: } 599: return performPluginUpdate({ 600: pluginId, 601: pluginName, 602: entry, 603: marketplaceInstallLocation, 604: installation, 605: scope, 606: projectPath, 607: }) 608: } 609: async function performPluginUpdate({ 610: pluginId, 611: pluginName, 612: entry, 613: marketplaceInstallLocation, 614: installation, 615: scope, 616: projectPath, 617: }: { 618: pluginId: string 619: pluginName: string 620: entry: PluginMarketplaceEntry 621: marketplaceInstallLocation: string 622: installation: { version?: string; installPath: string } 623: scope: PluginScope 624: projectPath: string | undefined 625: }): Promise<PluginUpdateResult> { 626: const fs = getFsImplementation() 627: const oldVersion = installation.version 628: let sourcePath: string 629: let newVersion: string 630: let shouldCleanupSource = false 631: let gitCommitSha: string | undefined 632: if (typeof entry.source !== 'string') { 633: const cacheResult = await cachePlugin(entry.source, { 634: manifest: { name: entry.name }, 635: }) 636: sourcePath = cacheResult.path 637: shouldCleanupSource = true 638: gitCommitSha = cacheResult.gitCommitSha 639: newVersion = await calculatePluginVersion( 640: pluginId, 641: entry.source, 642: cacheResult.manifest, 643: cacheResult.path, 644: entry.version, 645: cacheResult.gitCommitSha, 646: ) 647: } else { 648: let marketplaceStats 649: try { 650: marketplaceStats = await fs.stat(marketplaceInstallLocation) 651: } catch (e: unknown) { 652: if (isENOENT(e)) { 653: return { 654: success: false, 655: message: `Marketplace directory not found at ${marketplaceInstallLocation}`, 656: pluginId, 657: scope, 658: } 659: } 660: throw e 661: } 662: const marketplaceDir = marketplaceStats.isDirectory() 663: ? marketplaceInstallLocation 664: : dirname(marketplaceInstallLocation) 665: sourcePath = join(marketplaceDir, entry.source) 666: try { 667: await fs.stat(sourcePath) 668: } catch (e: unknown) { 669: if (isENOENT(e)) { 670: return { 671: success: false, 672: message: `Plugin source not found at ${sourcePath}`, 673: pluginId, 674: scope, 675: } 676: } 677: throw e 678: } 679: let pluginManifest: PluginManifest | undefined 680: const manifestPath = join(sourcePath, '.claude-plugin', 'plugin.json') 681: try { 682: pluginManifest = await loadPluginManifest( 683: manifestPath, 684: entry.name, 685: entry.source, 686: ) 687: } catch { 688: } 689: newVersion = await calculatePluginVersion( 690: pluginId, 691: entry.source, 692: pluginManifest, 693: sourcePath, 694: entry.version, 695: ) 696: } 697: try { 698: let versionedPath = getVersionedCachePath(pluginId, newVersion) 699: const zipPath = getVersionedZipCachePath(pluginId, newVersion) 700: const isUpToDate = 701: installation.version === newVersion || 702: installation.installPath === versionedPath || 703: installation.installPath === zipPath 704: if (isUpToDate) { 705: return { 706: success: true, 707: message: `${pluginName} is already at the latest version (${newVersion}).`, 708: pluginId, 709: newVersion, 710: oldVersion, 711: alreadyUpToDate: true, 712: scope, 713: } 714: } 715: versionedPath = await copyPluginToVersionedCache( 716: sourcePath, 717: pluginId, 718: newVersion, 719: entry, 720: ) 721: const oldVersionPath = installation.installPath 722: updateInstallationPathOnDisk( 723: pluginId, 724: scope, 725: projectPath, 726: versionedPath, 727: newVersion, 728: gitCommitSha, 729: ) 730: if (oldVersionPath && oldVersionPath !== versionedPath) { 731: const updatedDiskData = loadInstalledPluginsFromDisk() 732: const isOldVersionStillReferenced = Object.values( 733: updatedDiskData.plugins, 734: ).some(pluginInstallations => 735: pluginInstallations.some(inst => inst.installPath === oldVersionPath), 736: ) 737: if (!isOldVersionStillReferenced) { 738: await markPluginVersionOrphaned(oldVersionPath) 739: } 740: } 741: const scopeDesc = projectPath ? `${scope} (${projectPath})` : scope 742: const message = `Plugin "${pluginName}" updated from ${oldVersion || 'unknown'} to ${newVersion} for scope ${scopeDesc}. Restart to apply changes.` 743: return { 744: success: true, 745: message, 746: pluginId, 747: newVersion, 748: oldVersion, 749: scope, 750: } 751: } finally { 752: if ( 753: shouldCleanupSource && 754: sourcePath !== getVersionedCachePath(pluginId, newVersion) 755: ) { 756: await fs.rm(sourcePath, { recursive: true, force: true }) 757: } 758: } 759: }

File: src/services/policyLimits/index.ts

typescript 1: import axios from 'axios' 2: import { createHash } from 'crypto' 3: import { readFileSync as fsReadFileSync } from 'fs' 4: import { unlink, writeFile } from 'fs/promises' 5: import { join } from 'path' 6: import { 7: CLAUDE_AI_INFERENCE_SCOPE, 8: getOauthConfig, 9: OAUTH_BETA_HEADER, 10: } from '../../constants/oauth.js' 11: import { 12: checkAndRefreshOAuthTokenIfNeeded, 13: getAnthropicApiKeyWithSource, 14: getClaudeAIOAuthTokens, 15: } from '../../utils/auth.js' 16: import { registerCleanup } from '../../utils/cleanupRegistry.js' 17: import { logForDebugging } from '../../utils/debug.js' 18: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 19: import { classifyAxiosError } from '../../utils/errors.js' 20: import { safeParseJSON } from '../../utils/json.js' 21: import { 22: getAPIProvider, 23: isFirstPartyAnthropicBaseUrl, 24: } from '../../utils/model/providers.js' 25: import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js' 26: import { sleep } from '../../utils/sleep.js' 27: import { jsonStringify } from '../../utils/slowOperations.js' 28: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 29: import { getRetryDelay } from '../api/withRetry.js' 30: import { 31: type PolicyLimitsFetchResult, 32: type PolicyLimitsResponse, 33: PolicyLimitsResponseSchema, 34: } from './types.js' 35: function isNodeError(e: unknown): e is NodeJS.ErrnoException { 36: return e instanceof Error 37: } 38: const CACHE_FILENAME = 'policy-limits.json' 39: const FETCH_TIMEOUT_MS = 10000 40: const DEFAULT_MAX_RETRIES = 5 41: const POLLING_INTERVAL_MS = 60 * 60 * 1000 42: let pollingIntervalId: ReturnType<typeof setInterval> | null = null 43: let cleanupRegistered = false 44: let loadingCompletePromise: Promise<void> | null = null 45: let loadingCompleteResolve: (() => void) | null = null 46: const LOADING_PROMISE_TIMEOUT_MS = 30000 47: let sessionCache: PolicyLimitsResponse['restrictions'] | null = null 48: export function _resetPolicyLimitsForTesting(): void { 49: stopBackgroundPolling() 50: sessionCache = null 51: loadingCompletePromise = null 52: loadingCompleteResolve = null 53: } 54: export function initializePolicyLimitsLoadingPromise(): void { 55: if (loadingCompletePromise) { 56: return 57: } 58: if (isPolicyLimitsEligible()) { 59: loadingCompletePromise = new Promise(resolve => { 60: loadingCompleteResolve = resolve 61: setTimeout(() => { 62: if (loadingCompleteResolve) { 63: logForDebugging( 64: 'Policy limits: Loading promise timed out, resolving anyway', 65: ) 66: loadingCompleteResolve() 67: loadingCompleteResolve = null 68: } 69: }, LOADING_PROMISE_TIMEOUT_MS) 70: }) 71: } 72: } 73: function getCachePath(): string { 74: return join(getClaudeConfigHomeDir(), CACHE_FILENAME) 75: } 76: function getPolicyLimitsEndpoint(): string { 77: return `${getOauthConfig().BASE_API_URL}/api/claude_code/policy_limits` 78: } 79: function sortKeysDeep(obj: unknown): unknown { 80: if (Array.isArray(obj)) { 81: return obj.map(sortKeysDeep) 82: } 83: if (obj !== null && typeof obj === 'object') { 84: const sorted: Record<string, unknown> = {} 85: for (const [key, value] of Object.entries(obj).sort(([a], [b]) => 86: a.localeCompare(b), 87: )) { 88: sorted[key] = sortKeysDeep(value) 89: } 90: return sorted 91: } 92: return obj 93: } 94: function computeChecksum( 95: restrictions: PolicyLimitsResponse['restrictions'], 96: ): string { 97: const sorted = sortKeysDeep(restrictions) 98: const normalized = jsonStringify(sorted) 99: const hash = createHash('sha256').update(normalized).digest('hex') 100: return `sha256:${hash}` 101: } 102: export function isPolicyLimitsEligible(): boolean { 103: if (getAPIProvider() !== 'firstParty') { 104: return false 105: } 106: if (!isFirstPartyAnthropicBaseUrl()) { 107: return false 108: } 109: try { 110: const { key: apiKey } = getAnthropicApiKeyWithSource({ 111: skipRetrievingKeyFromApiKeyHelper: true, 112: }) 113: if (apiKey) { 114: return true 115: } 116: } catch { 117: } 118: const tokens = getClaudeAIOAuthTokens() 119: if (!tokens?.accessToken) { 120: return false 121: } 122: if (!tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE)) { 123: return false 124: } 125: if ( 126: tokens.subscriptionType !== 'enterprise' && 127: tokens.subscriptionType !== 'team' 128: ) { 129: return false 130: } 131: return true 132: } 133: export async function waitForPolicyLimitsToLoad(): Promise<void> { 134: if (loadingCompletePromise) { 135: await loadingCompletePromise 136: } 137: } 138: function getAuthHeaders(): { 139: headers: Record<string, string> 140: error?: string 141: } { 142: try { 143: const { key: apiKey } = getAnthropicApiKeyWithSource({ 144: skipRetrievingKeyFromApiKeyHelper: true, 145: }) 146: if (apiKey) { 147: return { 148: headers: { 149: 'x-api-key': apiKey, 150: }, 151: } 152: } 153: } catch { 154: } 155: const oauthTokens = getClaudeAIOAuthTokens() 156: if (oauthTokens?.accessToken) { 157: return { 158: headers: { 159: Authorization: `Bearer ${oauthTokens.accessToken}`, 160: 'anthropic-beta': OAUTH_BETA_HEADER, 161: }, 162: } 163: } 164: return { 165: headers: {}, 166: error: 'No authentication available', 167: } 168: } 169: async function fetchWithRetry( 170: cachedChecksum?: string, 171: ): Promise<PolicyLimitsFetchResult> { 172: let lastResult: PolicyLimitsFetchResult | null = null 173: for (let attempt = 1; attempt <= DEFAULT_MAX_RETRIES + 1; attempt++) { 174: lastResult = await fetchPolicyLimits(cachedChecksum) 175: if (lastResult.success) { 176: return lastResult 177: } 178: if (lastResult.skipRetry) { 179: return lastResult 180: } 181: if (attempt > DEFAULT_MAX_RETRIES) { 182: return lastResult 183: } 184: const delayMs = getRetryDelay(attempt) 185: logForDebugging( 186: `Policy limits: Retry ${attempt}/${DEFAULT_MAX_RETRIES} after ${delayMs}ms`, 187: ) 188: await sleep(delayMs) 189: } 190: return lastResult! 191: } 192: async function fetchPolicyLimits( 193: cachedChecksum?: string, 194: ): Promise<PolicyLimitsFetchResult> { 195: try { 196: await checkAndRefreshOAuthTokenIfNeeded() 197: const authHeaders = getAuthHeaders() 198: if (authHeaders.error) { 199: return { 200: success: false, 201: error: 'Authentication required for policy limits', 202: skipRetry: true, 203: } 204: } 205: const endpoint = getPolicyLimitsEndpoint() 206: const headers: Record<string, string> = { 207: ...authHeaders.headers, 208: 'User-Agent': getClaudeCodeUserAgent(), 209: } 210: if (cachedChecksum) { 211: headers['If-None-Match'] = `"${cachedChecksum}"` 212: } 213: const response = await axios.get(endpoint, { 214: headers, 215: timeout: FETCH_TIMEOUT_MS, 216: validateStatus: status => 217: status === 200 || status === 304 || status === 404, 218: }) 219: if (response.status === 304) { 220: logForDebugging('Policy limits: Using cached restrictions (304)') 221: return { 222: success: true, 223: restrictions: null, 224: etag: cachedChecksum, 225: } 226: } 227: if (response.status === 404) { 228: logForDebugging('Policy limits: No restrictions found (404)') 229: return { 230: success: true, 231: restrictions: {}, 232: etag: undefined, 233: } 234: } 235: const parsed = PolicyLimitsResponseSchema().safeParse(response.data) 236: if (!parsed.success) { 237: logForDebugging( 238: `Policy limits: Invalid response format - ${parsed.error.message}`, 239: ) 240: return { 241: success: false, 242: error: 'Invalid policy limits format', 243: } 244: } 245: logForDebugging('Policy limits: Fetched successfully') 246: return { 247: success: true, 248: restrictions: parsed.data.restrictions, 249: } 250: } catch (error) { 251: const { kind, message } = classifyAxiosError(error) 252: switch (kind) { 253: case 'auth': 254: return { 255: success: false, 256: error: 'Not authorized for policy limits', 257: skipRetry: true, 258: } 259: case 'timeout': 260: return { success: false, error: 'Policy limits request timeout' } 261: case 'network': 262: return { success: false, error: 'Cannot connect to server' } 263: default: 264: return { success: false, error: message } 265: } 266: } 267: } 268: function loadCachedRestrictions(): PolicyLimitsResponse['restrictions'] | null { 269: try { 270: const content = fsReadFileSync(getCachePath(), 'utf-8') 271: const data = safeParseJSON(content, false) 272: const parsed = PolicyLimitsResponseSchema().safeParse(data) 273: if (!parsed.success) { 274: return null 275: } 276: return parsed.data.restrictions 277: } catch { 278: return null 279: } 280: } 281: async function saveCachedRestrictions( 282: restrictions: PolicyLimitsResponse['restrictions'], 283: ): Promise<void> { 284: try { 285: const path = getCachePath() 286: const data: PolicyLimitsResponse = { restrictions } 287: await writeFile(path, jsonStringify(data, null, 2), { 288: encoding: 'utf-8', 289: mode: 0o600, 290: }) 291: logForDebugging(`Policy limits: Saved to ${path}`) 292: } catch (error) { 293: logForDebugging( 294: `Policy limits: Failed to save - ${error instanceof Error ? error.message : 'unknown error'}`, 295: ) 296: } 297: } 298: async function fetchAndLoadPolicyLimits(): Promise< 299: PolicyLimitsResponse['restrictions'] | null 300: > { 301: if (!isPolicyLimitsEligible()) { 302: return null 303: } 304: const cachedRestrictions = loadCachedRestrictions() 305: const cachedChecksum = cachedRestrictions 306: ? computeChecksum(cachedRestrictions) 307: : undefined 308: try { 309: const result = await fetchWithRetry(cachedChecksum) 310: if (!result.success) { 311: if (cachedRestrictions) { 312: logForDebugging('Policy limits: Using stale cache after fetch failure') 313: sessionCache = cachedRestrictions 314: return cachedRestrictions 315: } 316: return null 317: } 318: if (result.restrictions === null && cachedRestrictions) { 319: logForDebugging('Policy limits: Cache still valid (304 Not Modified)') 320: sessionCache = cachedRestrictions 321: return cachedRestrictions 322: } 323: const newRestrictions = result.restrictions || {} 324: const hasContent = Object.keys(newRestrictions).length > 0 325: if (hasContent) { 326: sessionCache = newRestrictions 327: await saveCachedRestrictions(newRestrictions) 328: logForDebugging('Policy limits: Applied new restrictions successfully') 329: return newRestrictions 330: } 331: sessionCache = newRestrictions 332: try { 333: await unlink(getCachePath()) 334: logForDebugging('Policy limits: Deleted cached file (404 response)') 335: } catch (e) { 336: if (isNodeError(e) && e.code !== 'ENOENT') { 337: logForDebugging( 338: `Policy limits: Failed to delete cached file - ${e.message}`, 339: ) 340: } 341: } 342: return newRestrictions 343: } catch { 344: if (cachedRestrictions) { 345: logForDebugging('Policy limits: Using stale cache after error') 346: sessionCache = cachedRestrictions 347: return cachedRestrictions 348: } 349: return null 350: } 351: } 352: const ESSENTIAL_TRAFFIC_DENY_ON_MISS = new Set(['allow_product_feedback']) 353: export function isPolicyAllowed(policy: string): boolean { 354: const restrictions = getRestrictionsFromCache() 355: if (!restrictions) { 356: if ( 357: isEssentialTrafficOnly() && 358: ESSENTIAL_TRAFFIC_DENY_ON_MISS.has(policy) 359: ) { 360: return false 361: } 362: return true 363: } 364: const restriction = restrictions[policy] 365: if (!restriction) { 366: return true 367: } 368: return restriction.allowed 369: } 370: function getRestrictionsFromCache(): 371: | PolicyLimitsResponse['restrictions'] 372: | null { 373: if (!isPolicyLimitsEligible()) { 374: return null 375: } 376: if (sessionCache) { 377: return sessionCache 378: } 379: const cachedRestrictions = loadCachedRestrictions() 380: if (cachedRestrictions) { 381: sessionCache = cachedRestrictions 382: return cachedRestrictions 383: } 384: return null 385: } 386: export async function loadPolicyLimits(): Promise<void> { 387: if (isPolicyLimitsEligible() && !loadingCompletePromise) { 388: loadingCompletePromise = new Promise(resolve => { 389: loadingCompleteResolve = resolve 390: }) 391: } 392: try { 393: await fetchAndLoadPolicyLimits() 394: if (isPolicyLimitsEligible()) { 395: startBackgroundPolling() 396: } 397: } finally { 398: if (loadingCompleteResolve) { 399: loadingCompleteResolve() 400: loadingCompleteResolve = null 401: } 402: } 403: } 404: export async function refreshPolicyLimits(): Promise<void> { 405: await clearPolicyLimitsCache() 406: if (!isPolicyLimitsEligible()) { 407: return 408: } 409: await fetchAndLoadPolicyLimits() 410: logForDebugging('Policy limits: Refreshed after auth change') 411: } 412: export async function clearPolicyLimitsCache(): Promise<void> { 413: stopBackgroundPolling() 414: sessionCache = null 415: loadingCompletePromise = null 416: loadingCompleteResolve = null 417: try { 418: await unlink(getCachePath()) 419: } catch { 420: } 421: } 422: async function pollPolicyLimits(): Promise<void> { 423: if (!isPolicyLimitsEligible()) { 424: return 425: } 426: const previousCache = sessionCache ? jsonStringify(sessionCache) : null 427: try { 428: await fetchAndLoadPolicyLimits() 429: const newCache = sessionCache ? jsonStringify(sessionCache) : null 430: if (newCache !== previousCache) { 431: logForDebugging('Policy limits: Changed during background poll') 432: } 433: } catch { 434: } 435: } 436: export function startBackgroundPolling(): void { 437: if (pollingIntervalId !== null) { 438: return 439: } 440: if (!isPolicyLimitsEligible()) { 441: return 442: } 443: pollingIntervalId = setInterval(() => { 444: void pollPolicyLimits() 445: }, POLLING_INTERVAL_MS) 446: pollingIntervalId.unref() 447: if (!cleanupRegistered) { 448: cleanupRegistered = true 449: registerCleanup(async () => stopBackgroundPolling()) 450: } 451: } 452: export function stopBackgroundPolling(): void { 453: if (pollingIntervalId !== null) { 454: clearInterval(pollingIntervalId) 455: pollingIntervalId = null 456: } 457: }

File: src/services/policyLimits/types.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: export const PolicyLimitsResponseSchema = lazySchema(() => 4: z.object({ 5: restrictions: z.record(z.string(), z.object({ allowed: z.boolean() })), 6: }), 7: ) 8: export type PolicyLimitsResponse = z.infer< 9: ReturnType<typeof PolicyLimitsResponseSchema> 10: > 11: export type PolicyLimitsFetchResult = { 12: success: boolean 13: restrictions?: PolicyLimitsResponse['restrictions'] | null 14: etag?: string 15: error?: string 16: skipRetry?: boolean 17: }

File: src/services/PromptSuggestion/promptSuggestion.ts

typescript 1: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 2: import type { AppState } from '../../state/AppState.js' 3: import type { Message } from '../../types/message.js' 4: import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' 5: import { count } from '../../utils/array.js' 6: import { isEnvDefinedFalsy, isEnvTruthy } from '../../utils/envUtils.js' 7: import { toError } from '../../utils/errors.js' 8: import { 9: type CacheSafeParams, 10: createCacheSafeParams, 11: runForkedAgent, 12: } from '../../utils/forkedAgent.js' 13: import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' 14: import { logError } from '../../utils/log.js' 15: import { 16: createUserMessage, 17: getLastAssistantMessage, 18: } from '../../utils/messages.js' 19: import { getInitialSettings } from '../../utils/settings/settings.js' 20: import { isTeammate } from '../../utils/teammate.js' 21: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 22: import { 23: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 24: logEvent, 25: } from '../analytics/index.js' 26: import { currentLimits } from '../claudeAiLimits.js' 27: import { isSpeculationEnabled, startSpeculation } from './speculation.js' 28: let currentAbortController: AbortController | null = null 29: export type PromptVariant = 'user_intent' | 'stated_intent' 30: export function getPromptVariant(): PromptVariant { 31: return 'user_intent' 32: } 33: export function shouldEnablePromptSuggestion(): boolean { 34: const envOverride = process.env.CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION 35: if (isEnvDefinedFalsy(envOverride)) { 36: logEvent('tengu_prompt_suggestion_init', { 37: enabled: false, 38: source: 39: 'env' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 40: }) 41: return false 42: } 43: if (isEnvTruthy(envOverride)) { 44: logEvent('tengu_prompt_suggestion_init', { 45: enabled: true, 46: source: 47: 'env' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 48: }) 49: return true 50: } 51: if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_chomp_inflection', false)) { 52: logEvent('tengu_prompt_suggestion_init', { 53: enabled: false, 54: source: 55: 'growthbook' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 56: }) 57: return false 58: } 59: if (getIsNonInteractiveSession()) { 60: logEvent('tengu_prompt_suggestion_init', { 61: enabled: false, 62: source: 63: 'non_interactive' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 64: }) 65: return false 66: } 67: if (isAgentSwarmsEnabled() && isTeammate()) { 68: logEvent('tengu_prompt_suggestion_init', { 69: enabled: false, 70: source: 71: 'swarm_teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 72: }) 73: return false 74: } 75: const enabled = getInitialSettings()?.promptSuggestionEnabled !== false 76: logEvent('tengu_prompt_suggestion_init', { 77: enabled, 78: source: 79: 'setting' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 80: }) 81: return enabled 82: } 83: export function abortPromptSuggestion(): void { 84: if (currentAbortController) { 85: currentAbortController.abort() 86: currentAbortController = null 87: } 88: } 89: export function getSuggestionSuppressReason(appState: AppState): string | null { 90: if (!appState.promptSuggestionEnabled) return 'disabled' 91: if (appState.pendingWorkerRequest || appState.pendingSandboxRequest) 92: return 'pending_permission' 93: if (appState.elicitation.queue.length > 0) return 'elicitation_active' 94: if (appState.toolPermissionContext.mode === 'plan') return 'plan_mode' 95: if ( 96: process.env.USER_TYPE === 'external' && 97: currentLimits.status !== 'allowed' 98: ) 99: return 'rate_limit' 100: return null 101: } 102: export async function tryGenerateSuggestion( 103: abortController: AbortController, 104: messages: Message[], 105: getAppState: () => AppState, 106: cacheSafeParams: CacheSafeParams, 107: source?: 'cli' | 'sdk', 108: ): Promise<{ 109: suggestion: string 110: promptId: PromptVariant 111: generationRequestId: string | null 112: } | null> { 113: if (abortController.signal.aborted) { 114: logSuggestionSuppressed('aborted', undefined, undefined, source) 115: return null 116: } 117: const assistantTurnCount = count(messages, m => m.type === 'assistant') 118: if (assistantTurnCount < 2) { 119: logSuggestionSuppressed('early_conversation', undefined, undefined, source) 120: return null 121: } 122: const lastAssistantMessage = getLastAssistantMessage(messages) 123: if (lastAssistantMessage?.isApiErrorMessage) { 124: logSuggestionSuppressed('last_response_error', undefined, undefined, source) 125: return null 126: } 127: const cacheReason = getParentCacheSuppressReason(lastAssistantMessage) 128: if (cacheReason) { 129: logSuggestionSuppressed(cacheReason, undefined, undefined, source) 130: return null 131: } 132: const appState = getAppState() 133: const suppressReason = getSuggestionSuppressReason(appState) 134: if (suppressReason) { 135: logSuggestionSuppressed(suppressReason, undefined, undefined, source) 136: return null 137: } 138: const promptId = getPromptVariant() 139: const { suggestion, generationRequestId } = await generateSuggestion( 140: abortController, 141: promptId, 142: cacheSafeParams, 143: ) 144: if (abortController.signal.aborted) { 145: logSuggestionSuppressed('aborted', undefined, undefined, source) 146: return null 147: } 148: if (!suggestion) { 149: logSuggestionSuppressed('empty', undefined, promptId, source) 150: return null 151: } 152: if (shouldFilterSuggestion(suggestion, promptId, source)) return null 153: return { suggestion, promptId, generationRequestId } 154: } 155: export async function executePromptSuggestion( 156: context: REPLHookContext, 157: ): Promise<void> { 158: if (context.querySource !== 'repl_main_thread') return 159: currentAbortController = new AbortController() 160: const abortController = currentAbortController 161: const cacheSafeParams = createCacheSafeParams(context) 162: try { 163: const result = await tryGenerateSuggestion( 164: abortController, 165: context.messages, 166: context.toolUseContext.getAppState, 167: cacheSafeParams, 168: 'cli', 169: ) 170: if (!result) return 171: context.toolUseContext.setAppState(prev => ({ 172: ...prev, 173: promptSuggestion: { 174: text: result.suggestion, 175: promptId: result.promptId, 176: shownAt: 0, 177: acceptedAt: 0, 178: generationRequestId: result.generationRequestId, 179: }, 180: })) 181: if (isSpeculationEnabled() && result.suggestion) { 182: void startSpeculation( 183: result.suggestion, 184: context, 185: context.toolUseContext.setAppState, 186: false, 187: cacheSafeParams, 188: ) 189: } 190: } catch (error) { 191: if ( 192: error instanceof Error && 193: (error.name === 'AbortError' || error.name === 'APIUserAbortError') 194: ) { 195: logSuggestionSuppressed('aborted', undefined, undefined, 'cli') 196: return 197: } 198: logError(toError(error)) 199: } finally { 200: if (currentAbortController === abortController) { 201: currentAbortController = null 202: } 203: } 204: } 205: const MAX_PARENT_UNCACHED_TOKENS = 10_000 206: export function getParentCacheSuppressReason( 207: lastAssistantMessage: ReturnType<typeof getLastAssistantMessage>, 208: ): string | null { 209: if (!lastAssistantMessage) return null 210: const usage = lastAssistantMessage.message.usage 211: const inputTokens = usage.input_tokens ?? 0 212: const cacheWriteTokens = usage.cache_creation_input_tokens ?? 0 213: const outputTokens = usage.output_tokens ?? 0 214: return inputTokens + cacheWriteTokens + outputTokens > 215: MAX_PARENT_UNCACHED_TOKENS 216: ? 'cache_cold' 217: : null 218: } 219: const SUGGESTION_PROMPT = `[SUGGESTION MODE: Suggest what the user might naturally type next into Claude Code.] 220: FIRST: Look at the user's recent messages and original request. 221: Your job is to predict what THEY would type - not what you think they should do. 222: THE TEST: Would they think "I was just about to type that"? 223: EXAMPLES: 224: User asked "fix the bug and run tests", bug is fixed → "run the tests" 225: After code written → "try it out" 226: Claude offers options → suggest the one the user would likely pick, based on conversation 227: Claude asks to continue → "yes" or "go ahead" 228: Task complete, obvious follow-up → "commit this" or "push it" 229: After error or misunderstanding → silence (let them assess/correct) 230: Be specific: "run the tests" beats "continue". 231: NEVER SUGGEST: 232: - Evaluative ("looks good", "thanks") 233: - Questions ("what about...?") 234: - Claude-voice ("Let me...", "I'll...", "Here's...") 235: - New ideas they didn't ask about 236: - Multiple sentences 237: Stay silent if the next step isn't obvious from what the user said. 238: Format: 2-12 words, match the user's style. Or nothing. 239: Reply with ONLY the suggestion, no quotes or explanation.` 240: const SUGGESTION_PROMPTS: Record<PromptVariant, string> = { 241: user_intent: SUGGESTION_PROMPT, 242: stated_intent: SUGGESTION_PROMPT, 243: } 244: export async function generateSuggestion( 245: abortController: AbortController, 246: promptId: PromptVariant, 247: cacheSafeParams: CacheSafeParams, 248: ): Promise<{ suggestion: string | null; generationRequestId: string | null }> { 249: const prompt = SUGGESTION_PROMPTS[promptId] 250: const canUseTool = async () => ({ 251: behavior: 'deny' as const, 252: message: 'No tools needed for suggestion', 253: decisionReason: { type: 'other' as const, reason: 'suggestion only' }, 254: }) 255: const result = await runForkedAgent({ 256: promptMessages: [createUserMessage({ content: prompt })], 257: cacheSafeParams, 258: canUseTool, 259: querySource: 'prompt_suggestion', 260: forkLabel: 'prompt_suggestion', 261: overrides: { 262: abortController, 263: }, 264: skipTranscript: true, 265: skipCacheWrite: true, 266: }) 267: const firstAssistantMsg = result.messages.find(m => m.type === 'assistant') 268: const generationRequestId = 269: firstAssistantMsg?.type === 'assistant' 270: ? (firstAssistantMsg.requestId ?? null) 271: : null 272: for (const msg of result.messages) { 273: if (msg.type !== 'assistant') continue 274: const textBlock = msg.message.content.find(b => b.type === 'text') 275: if (textBlock?.type === 'text') { 276: const suggestion = textBlock.text.trim() 277: if (suggestion) { 278: return { suggestion, generationRequestId } 279: } 280: } 281: } 282: return { suggestion: null, generationRequestId } 283: } 284: export function shouldFilterSuggestion( 285: suggestion: string | null, 286: promptId: PromptVariant, 287: source?: 'cli' | 'sdk', 288: ): boolean { 289: if (!suggestion) { 290: logSuggestionSuppressed('empty', undefined, promptId, source) 291: return true 292: } 293: const lower = suggestion.toLowerCase() 294: const wordCount = suggestion.trim().split(/\s+/).length 295: const filters: Array<[string, () => boolean]> = [ 296: ['done', () => lower === 'done'], 297: [ 298: 'meta_text', 299: () => 300: lower === 'nothing found' || 301: lower === 'nothing found.' || 302: lower.startsWith('nothing to suggest') || 303: lower.startsWith('no suggestion') || 304: /\bsilence is\b|\bstay(s|ing)? silent\b/.test(lower) || 305: /^\W*silence\W*$/.test(lower), 306: ], 307: [ 308: 'meta_wrapped', 309: () => /^\(.*\)$|^\[.*\]$/.test(suggestion), 310: ], 311: [ 312: 'error_message', 313: () => 314: lower.startsWith('api error:') || 315: lower.startsWith('prompt is too long') || 316: lower.startsWith('request timed out') || 317: lower.startsWith('invalid api key') || 318: lower.startsWith('image was too large'), 319: ], 320: ['prefixed_label', () => /^\w+:\s/.test(suggestion)], 321: [ 322: 'too_few_words', 323: () => { 324: if (wordCount >= 2) return false 325: if (suggestion.startsWith('/')) return false 326: const ALLOWED_SINGLE_WORDS = new Set([ 327: 'yes', 328: 'yeah', 329: 'yep', 330: 'yea', 331: 'yup', 332: 'sure', 333: 'ok', 334: 'okay', 335: 'push', 336: 'commit', 337: 'deploy', 338: 'stop', 339: 'continue', 340: 'check', 341: 'exit', 342: 'quit', 343: 'no', 344: ]) 345: return !ALLOWED_SINGLE_WORDS.has(lower) 346: }, 347: ], 348: ['too_many_words', () => wordCount > 12], 349: ['too_long', () => suggestion.length >= 100], 350: ['multiple_sentences', () => /[.!?]\s+[A-Z]/.test(suggestion)], 351: ['has_formatting', () => /[\n*]|\*\*/.test(suggestion)], 352: [ 353: 'evaluative', 354: () => 355: /thanks|thank you|looks good|sounds good|that works|that worked|that's all|nice|great|perfect|makes sense|awesome|excellent/.test( 356: lower, 357: ), 358: ], 359: [ 360: 'claude_voice', 361: () => 362: /^(let me|i'll|i've|i'm|i can|i would|i think|i notice|here's|here is|here are|that's|this is|this will|you can|you should|you could|sure,|of course|certainly)/i.test( 363: suggestion, 364: ), 365: ], 366: ] 367: for (const [reason, check] of filters) { 368: if (check()) { 369: logSuggestionSuppressed(reason, suggestion, promptId, source) 370: return true 371: } 372: } 373: return false 374: } 375: export function logSuggestionOutcome( 376: suggestion: string, 377: userInput: string, 378: emittedAt: number, 379: promptId: PromptVariant, 380: generationRequestId: string | null, 381: ): void { 382: const similarity = 383: Math.round((userInput.length / (suggestion.length || 1)) * 100) / 100 384: const wasAccepted = userInput === suggestion 385: const timeMs = Math.max(0, Date.now() - emittedAt) 386: logEvent('tengu_prompt_suggestion', { 387: source: 'sdk' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 388: outcome: (wasAccepted 389: ? 'accepted' 390: : 'ignored') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 391: prompt_id: 392: promptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 393: ...(generationRequestId && { 394: generationRequestId: 395: generationRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 396: }), 397: ...(wasAccepted && { 398: timeToAcceptMs: timeMs, 399: }), 400: ...(!wasAccepted && { timeToIgnoreMs: timeMs }), 401: similarity, 402: ...(process.env.USER_TYPE === 'ant' && { 403: suggestion: 404: suggestion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 405: userInput: 406: userInput as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 407: }), 408: }) 409: } 410: export function logSuggestionSuppressed( 411: reason: string, 412: suggestion?: string, 413: promptId?: PromptVariant, 414: source?: 'cli' | 'sdk', 415: ): void { 416: const resolvedPromptId = promptId ?? getPromptVariant() 417: logEvent('tengu_prompt_suggestion', { 418: ...(source && { 419: source: 420: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 421: }), 422: outcome: 423: 'suppressed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 424: reason: 425: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 426: prompt_id: 427: resolvedPromptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 428: ...(process.env.USER_TYPE === 'ant' && 429: suggestion && { 430: suggestion: 431: suggestion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 432: }), 433: }) 434: }

File: src/services/PromptSuggestion/speculation.ts

typescript 1: import { randomUUID } from 'crypto' 2: import { rm } from 'fs' 3: import { appendFile, copyFile, mkdir } from 'fs/promises' 4: import { dirname, isAbsolute, join, relative } from 'path' 5: import { getCwdState } from '../../bootstrap/state.js' 6: import type { CompletionBoundary } from '../../state/AppStateStore.js' 7: import { 8: type AppState, 9: IDLE_SPECULATION_STATE, 10: type SpeculationResult, 11: type SpeculationState, 12: } from '../../state/AppStateStore.js' 13: import { commandHasAnyCd } from '../../tools/BashTool/bashPermissions.js' 14: import { checkReadOnlyConstraints } from '../../tools/BashTool/readOnlyValidation.js' 15: import type { SpeculationAcceptMessage } from '../../types/logs.js' 16: import type { Message } from '../../types/message.js' 17: import { createChildAbortController } from '../../utils/abortController.js' 18: import { count } from '../../utils/array.js' 19: import { getGlobalConfig } from '../../utils/config.js' 20: import { logForDebugging } from '../../utils/debug.js' 21: import { errorMessage } from '../../utils/errors.js' 22: import { 23: type FileStateCache, 24: mergeFileStateCaches, 25: READ_FILE_STATE_CACHE_SIZE, 26: } from '../../utils/fileStateCache.js' 27: import { 28: type CacheSafeParams, 29: createCacheSafeParams, 30: runForkedAgent, 31: } from '../../utils/forkedAgent.js' 32: import { formatDuration, formatNumber } from '../../utils/format.js' 33: import type { REPLHookContext } from '../../utils/hooks/postSamplingHooks.js' 34: import { logError } from '../../utils/log.js' 35: import type { SetAppState } from '../../utils/messageQueueManager.js' 36: import { 37: createSystemMessage, 38: createUserMessage, 39: INTERRUPT_MESSAGE, 40: INTERRUPT_MESSAGE_FOR_TOOL_USE, 41: } from '../../utils/messages.js' 42: import { getClaudeTempDir } from '../../utils/permissions/filesystem.js' 43: import { extractReadFilesFromMessages } from '../../utils/queryHelpers.js' 44: import { getTranscriptPath } from '../../utils/sessionStorage.js' 45: import { jsonStringify } from '../../utils/slowOperations.js' 46: import { 47: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 48: logEvent, 49: } from '../analytics/index.js' 50: import { 51: generateSuggestion, 52: getPromptVariant, 53: getSuggestionSuppressReason, 54: logSuggestionSuppressed, 55: shouldFilterSuggestion, 56: } from './promptSuggestion.js' 57: const MAX_SPECULATION_TURNS = 20 58: const MAX_SPECULATION_MESSAGES = 100 59: const WRITE_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit']) 60: const SAFE_READ_ONLY_TOOLS = new Set([ 61: 'Read', 62: 'Glob', 63: 'Grep', 64: 'ToolSearch', 65: 'LSP', 66: 'TaskGet', 67: 'TaskList', 68: ]) 69: function safeRemoveOverlay(overlayPath: string): void { 70: rm( 71: overlayPath, 72: { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }, 73: () => {}, 74: ) 75: } 76: function getOverlayPath(id: string): string { 77: return join(getClaudeTempDir(), 'speculation', String(process.pid), id) 78: } 79: function denySpeculation( 80: message: string, 81: reason: string, 82: ): { 83: behavior: 'deny' 84: message: string 85: decisionReason: { type: 'other'; reason: string } 86: } { 87: return { 88: behavior: 'deny', 89: message, 90: decisionReason: { type: 'other', reason }, 91: } 92: } 93: async function copyOverlayToMain( 94: overlayPath: string, 95: writtenPaths: Set<string>, 96: cwd: string, 97: ): Promise<boolean> { 98: let allCopied = true 99: for (const rel of writtenPaths) { 100: const src = join(overlayPath, rel) 101: const dest = join(cwd, rel) 102: try { 103: await mkdir(dirname(dest), { recursive: true }) 104: await copyFile(src, dest) 105: } catch { 106: allCopied = false 107: logForDebugging(`[Speculation] Failed to copy ${rel} to main`) 108: } 109: } 110: return allCopied 111: } 112: export type ActiveSpeculationState = Extract< 113: SpeculationState, 114: { status: 'active' } 115: > 116: function logSpeculation( 117: id: string, 118: outcome: 'accepted' | 'aborted' | 'error', 119: startTime: number, 120: suggestionLength: number, 121: messages: Message[], 122: boundary: CompletionBoundary | null, 123: extras?: Record<string, string | number | boolean | undefined>, 124: ): void { 125: logEvent('tengu_speculation', { 126: speculation_id: 127: id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 128: outcome: 129: outcome as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 130: duration_ms: Date.now() - startTime, 131: suggestion_length: suggestionLength, 132: tools_executed: countToolsInMessages(messages), 133: completed: boundary !== null, 134: boundary_type: boundary?.type as 135: | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 136: | undefined, 137: boundary_tool: getBoundaryTool(boundary) as 138: | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 139: | undefined, 140: boundary_detail: getBoundaryDetail(boundary) as 141: | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 142: | undefined, 143: ...extras, 144: }) 145: } 146: function countToolsInMessages(messages: Message[]): number { 147: const blocks = messages 148: .filter(isUserMessageWithArrayContent) 149: .flatMap(m => m.message.content) 150: .filter( 151: (b): b is { type: string; is_error?: boolean } => 152: typeof b === 'object' && b !== null && 'type' in b, 153: ) 154: return count(blocks, b => b.type === 'tool_result' && !b.is_error) 155: } 156: function getBoundaryTool( 157: boundary: CompletionBoundary | null, 158: ): string | undefined { 159: if (!boundary) return undefined 160: switch (boundary.type) { 161: case 'bash': 162: return 'Bash' 163: case 'edit': 164: case 'denied_tool': 165: return boundary.toolName 166: case 'complete': 167: return undefined 168: } 169: } 170: function getBoundaryDetail( 171: boundary: CompletionBoundary | null, 172: ): string | undefined { 173: if (!boundary) return undefined 174: switch (boundary.type) { 175: case 'bash': 176: return boundary.command.slice(0, 200) 177: case 'edit': 178: return boundary.filePath 179: case 'denied_tool': 180: return boundary.detail 181: case 'complete': 182: return undefined 183: } 184: } 185: function isUserMessageWithArrayContent( 186: m: Message, 187: ): m is Message & { message: { content: unknown[] } } { 188: return m.type === 'user' && 'message' in m && Array.isArray(m.message.content) 189: } 190: export function prepareMessagesForInjection(messages: Message[]): Message[] { 191: type ToolResult = { 192: type: 'tool_result' 193: tool_use_id: string 194: is_error?: boolean 195: content?: unknown 196: } 197: const isToolResult = (b: unknown): b is ToolResult => 198: typeof b === 'object' && 199: b !== null && 200: (b as ToolResult).type === 'tool_result' && 201: typeof (b as ToolResult).tool_use_id === 'string' 202: const isSuccessful = (b: ToolResult) => 203: !b.is_error && 204: !( 205: typeof b.content === 'string' && 206: b.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE) 207: ) 208: const toolIdsWithSuccessfulResults = new Set( 209: messages 210: .filter(isUserMessageWithArrayContent) 211: .flatMap(m => m.message.content) 212: .filter(isToolResult) 213: .filter(isSuccessful) 214: .map(b => b.tool_use_id), 215: ) 216: const keep = (b: { 217: type: string 218: id?: string 219: tool_use_id?: string 220: text?: string 221: }) => 222: b.type !== 'thinking' && 223: b.type !== 'redacted_thinking' && 224: !(b.type === 'tool_use' && !toolIdsWithSuccessfulResults.has(b.id!)) && 225: !( 226: b.type === 'tool_result' && 227: !toolIdsWithSuccessfulResults.has(b.tool_use_id!) 228: ) && 229: !( 230: b.type === 'text' && 231: (b.text === INTERRUPT_MESSAGE || 232: b.text === INTERRUPT_MESSAGE_FOR_TOOL_USE) 233: ) 234: return messages 235: .map(msg => { 236: if (!('message' in msg) || !Array.isArray(msg.message.content)) return msg 237: const content = msg.message.content.filter(keep) 238: if (content.length === msg.message.content.length) return msg 239: if (content.length === 0) return null 240: const hasNonWhitespaceContent = content.some( 241: (b: { type: string; text?: string }) => 242: b.type !== 'text' || (b.text !== undefined && b.text.trim() !== ''), 243: ) 244: if (!hasNonWhitespaceContent) return null 245: return { ...msg, message: { ...msg.message, content } } as typeof msg 246: }) 247: .filter((m): m is Message => m !== null) 248: } 249: function createSpeculationFeedbackMessage( 250: messages: Message[], 251: boundary: CompletionBoundary | null, 252: timeSavedMs: number, 253: sessionTotalMs: number, 254: ): Message | null { 255: if (process.env.USER_TYPE !== 'ant') return null 256: if (messages.length === 0 || timeSavedMs === 0) return null 257: const toolUses = countToolsInMessages(messages) 258: const tokens = boundary?.type === 'complete' ? boundary.outputTokens : null 259: const parts = [] 260: if (toolUses > 0) { 261: parts.push(`Speculated ${toolUses} tool ${toolUses === 1 ? 'use' : 'uses'}`) 262: } else { 263: const turns = messages.length 264: parts.push(`Speculated ${turns} ${turns === 1 ? 'turn' : 'turns'}`) 265: } 266: if (tokens !== null) { 267: parts.push(`${formatNumber(tokens)} tokens`) 268: } 269: const savedText = `+${formatDuration(timeSavedMs)} saved` 270: const sessionSuffix = 271: sessionTotalMs !== timeSavedMs 272: ? ` (${formatDuration(sessionTotalMs)} this session)` 273: : '' 274: return createSystemMessage( 275: `[ANT-ONLY] ${parts.join(' · ')} · ${savedText}${sessionSuffix}`, 276: 'warning', 277: ) 278: } 279: function updateActiveSpeculationState( 280: setAppState: SetAppState, 281: updater: (state: ActiveSpeculationState) => Partial<ActiveSpeculationState>, 282: ): void { 283: setAppState(prev => { 284: if (prev.speculation.status !== 'active') return prev 285: const current = prev.speculation as ActiveSpeculationState 286: const updates = updater(current) 287: const hasChanges = Object.entries(updates).some( 288: ([key, value]) => current[key as keyof ActiveSpeculationState] !== value, 289: ) 290: if (!hasChanges) return prev 291: return { 292: ...prev, 293: speculation: { ...current, ...updates }, 294: } 295: }) 296: } 297: function resetSpeculationState(setAppState: SetAppState): void { 298: setAppState(prev => { 299: if (prev.speculation.status === 'idle') return prev 300: return { ...prev, speculation: IDLE_SPECULATION_STATE } 301: }) 302: } 303: export function isSpeculationEnabled(): boolean { 304: const enabled = 305: process.env.USER_TYPE === 'ant' && 306: (getGlobalConfig().speculationEnabled ?? true) 307: logForDebugging(`[Speculation] enabled=${enabled}`) 308: return enabled 309: } 310: async function generatePipelinedSuggestion( 311: context: REPLHookContext, 312: suggestionText: string, 313: speculatedMessages: Message[], 314: setAppState: SetAppState, 315: parentAbortController: AbortController, 316: ): Promise<void> { 317: try { 318: const appState = context.toolUseContext.getAppState() 319: const suppressReason = getSuggestionSuppressReason(appState) 320: if (suppressReason) { 321: logSuggestionSuppressed(`pipeline_${suppressReason}`) 322: return 323: } 324: const augmentedContext: REPLHookContext = { 325: ...context, 326: messages: [ 327: ...context.messages, 328: createUserMessage({ content: suggestionText }), 329: ...speculatedMessages, 330: ], 331: } 332: const pipelineAbortController = createChildAbortController( 333: parentAbortController, 334: ) 335: if (pipelineAbortController.signal.aborted) return 336: const promptId = getPromptVariant() 337: const { suggestion, generationRequestId } = await generateSuggestion( 338: pipelineAbortController, 339: promptId, 340: createCacheSafeParams(augmentedContext), 341: ) 342: if (pipelineAbortController.signal.aborted) return 343: if (shouldFilterSuggestion(suggestion, promptId)) return 344: logForDebugging( 345: `[Speculation] Pipelined suggestion: "${suggestion!.slice(0, 50)}..."`, 346: ) 347: updateActiveSpeculationState(setAppState, () => ({ 348: pipelinedSuggestion: { 349: text: suggestion!, 350: promptId, 351: generationRequestId, 352: }, 353: })) 354: } catch (error) { 355: if (error instanceof Error && error.name === 'AbortError') return 356: logForDebugging( 357: `[Speculation] Pipelined suggestion failed: ${errorMessage(error)}`, 358: ) 359: } 360: } 361: export async function startSpeculation( 362: suggestionText: string, 363: context: REPLHookContext, 364: setAppState: (f: (prev: AppState) => AppState) => void, 365: isPipelined = false, 366: cacheSafeParams?: CacheSafeParams, 367: ): Promise<void> { 368: if (!isSpeculationEnabled()) return 369: abortSpeculation(setAppState) 370: const id = randomUUID().slice(0, 8) 371: const abortController = createChildAbortController( 372: context.toolUseContext.abortController, 373: ) 374: if (abortController.signal.aborted) return 375: const startTime = Date.now() 376: const messagesRef = { current: [] as Message[] } 377: const writtenPathsRef = { current: new Set<string>() } 378: const overlayPath = getOverlayPath(id) 379: const cwd = getCwdState() 380: try { 381: await mkdir(overlayPath, { recursive: true }) 382: } catch { 383: logForDebugging('[Speculation] Failed to create overlay directory') 384: return 385: } 386: const contextRef = { current: context } 387: setAppState(prev => ({ 388: ...prev, 389: speculation: { 390: status: 'active', 391: id, 392: abort: () => abortController.abort(), 393: startTime, 394: messagesRef, 395: writtenPathsRef, 396: boundary: null, 397: suggestionLength: suggestionText.length, 398: toolUseCount: 0, 399: isPipelined, 400: contextRef, 401: }, 402: })) 403: logForDebugging(`[Speculation] Starting speculation ${id}`) 404: try { 405: const result = await runForkedAgent({ 406: promptMessages: [createUserMessage({ content: suggestionText })], 407: cacheSafeParams: cacheSafeParams ?? createCacheSafeParams(context), 408: skipTranscript: true, 409: canUseTool: async (tool, input) => { 410: const isWriteTool = WRITE_TOOLS.has(tool.name) 411: const isSafeReadOnlyTool = SAFE_READ_ONLY_TOOLS.has(tool.name) 412: if (isWriteTool) { 413: const appState = context.toolUseContext.getAppState() 414: const { mode, isBypassPermissionsModeAvailable } = 415: appState.toolPermissionContext 416: const canAutoAcceptEdits = 417: mode === 'acceptEdits' || 418: mode === 'bypassPermissions' || 419: (mode === 'plan' && isBypassPermissionsModeAvailable) 420: if (!canAutoAcceptEdits) { 421: logForDebugging(`[Speculation] Stopping at file edit: ${tool.name}`) 422: const editPath = ( 423: 'file_path' in input ? input.file_path : undefined 424: ) as string | undefined 425: updateActiveSpeculationState(setAppState, () => ({ 426: boundary: { 427: type: 'edit', 428: toolName: tool.name, 429: filePath: editPath ?? '', 430: completedAt: Date.now(), 431: }, 432: })) 433: abortController.abort() 434: return denySpeculation( 435: 'Speculation paused: file edit requires permission', 436: 'speculation_edit_boundary', 437: ) 438: } 439: } 440: if (isWriteTool || isSafeReadOnlyTool) { 441: const pathKey = 442: 'notebook_path' in input 443: ? 'notebook_path' 444: : 'path' in input 445: ? 'path' 446: : 'file_path' 447: const filePath = input[pathKey] as string | undefined 448: if (filePath) { 449: const rel = relative(cwd, filePath) 450: if (isAbsolute(rel) || rel.startsWith('..')) { 451: if (isWriteTool) { 452: logForDebugging( 453: `[Speculation] Denied ${tool.name}: path outside cwd: ${filePath}`, 454: ) 455: return denySpeculation( 456: 'Write outside cwd not allowed during speculation', 457: 'speculation_write_outside_root', 458: ) 459: } 460: return { 461: behavior: 'allow' as const, 462: updatedInput: input, 463: decisionReason: { 464: type: 'other' as const, 465: reason: 'speculation_read_outside_root', 466: }, 467: } 468: } 469: if (isWriteTool) { 470: if (!writtenPathsRef.current.has(rel)) { 471: const overlayFile = join(overlayPath, rel) 472: await mkdir(dirname(overlayFile), { recursive: true }) 473: try { 474: await copyFile(join(cwd, rel), overlayFile) 475: } catch { 476: } 477: writtenPathsRef.current.add(rel) 478: } 479: input = { ...input, [pathKey]: join(overlayPath, rel) } 480: } else { 481: if (writtenPathsRef.current.has(rel)) { 482: input = { ...input, [pathKey]: join(overlayPath, rel) } 483: } 484: } 485: logForDebugging( 486: `[Speculation] ${isWriteTool ? 'Write' : 'Read'} ${filePath} -> ${input[pathKey]}`, 487: ) 488: return { 489: behavior: 'allow' as const, 490: updatedInput: input, 491: decisionReason: { 492: type: 'other' as const, 493: reason: 'speculation_file_access', 494: }, 495: } 496: } 497: if (isSafeReadOnlyTool) { 498: return { 499: behavior: 'allow' as const, 500: updatedInput: input, 501: decisionReason: { 502: type: 'other' as const, 503: reason: 'speculation_read_default_cwd', 504: }, 505: } 506: } 507: } 508: if (tool.name === 'Bash') { 509: const command = 510: 'command' in input && typeof input.command === 'string' 511: ? input.command 512: : '' 513: if ( 514: !command || 515: checkReadOnlyConstraints({ command }, commandHasAnyCd(command)) 516: .behavior !== 'allow' 517: ) { 518: logForDebugging( 519: `[Speculation] Stopping at bash: ${command.slice(0, 50) || 'missing command'}`, 520: ) 521: updateActiveSpeculationState(setAppState, () => ({ 522: boundary: { type: 'bash', command, completedAt: Date.now() }, 523: })) 524: abortController.abort() 525: return denySpeculation( 526: 'Speculation paused: bash boundary', 527: 'speculation_bash_boundary', 528: ) 529: } 530: return { 531: behavior: 'allow' as const, 532: updatedInput: input, 533: decisionReason: { 534: type: 'other' as const, 535: reason: 'speculation_readonly_bash', 536: }, 537: } 538: } 539: logForDebugging(`[Speculation] Stopping at denied tool: ${tool.name}`) 540: const detail = String( 541: ('url' in input && input.url) || 542: ('file_path' in input && input.file_path) || 543: ('path' in input && input.path) || 544: ('command' in input && input.command) || 545: '', 546: ).slice(0, 200) 547: updateActiveSpeculationState(setAppState, () => ({ 548: boundary: { 549: type: 'denied_tool', 550: toolName: tool.name, 551: detail, 552: completedAt: Date.now(), 553: }, 554: })) 555: abortController.abort() 556: return denySpeculation( 557: `Tool ${tool.name} not allowed during speculation`, 558: 'speculation_unknown_tool', 559: ) 560: }, 561: querySource: 'speculation', 562: forkLabel: 'speculation', 563: maxTurns: MAX_SPECULATION_TURNS, 564: overrides: { abortController, requireCanUseTool: true }, 565: onMessage: msg => { 566: if (msg.type === 'assistant' || msg.type === 'user') { 567: messagesRef.current.push(msg) 568: if (messagesRef.current.length >= MAX_SPECULATION_MESSAGES) { 569: abortController.abort() 570: } 571: if (isUserMessageWithArrayContent(msg)) { 572: const newTools = count( 573: msg.message.content as { type: string; is_error?: boolean }[], 574: b => b.type === 'tool_result' && !b.is_error, 575: ) 576: if (newTools > 0) { 577: updateActiveSpeculationState(setAppState, prev => ({ 578: toolUseCount: prev.toolUseCount + newTools, 579: })) 580: } 581: } 582: } 583: }, 584: }) 585: if (abortController.signal.aborted) return 586: updateActiveSpeculationState(setAppState, () => ({ 587: boundary: { 588: type: 'complete' as const, 589: completedAt: Date.now(), 590: outputTokens: result.totalUsage.output_tokens, 591: }, 592: })) 593: logForDebugging( 594: `[Speculation] Complete: ${countToolsInMessages(messagesRef.current)} tools`, 595: ) 596: void generatePipelinedSuggestion( 597: contextRef.current, 598: suggestionText, 599: messagesRef.current, 600: setAppState, 601: abortController, 602: ) 603: } catch (error) { 604: abortController.abort() 605: if (error instanceof Error && error.name === 'AbortError') { 606: safeRemoveOverlay(overlayPath) 607: resetSpeculationState(setAppState) 608: return 609: } 610: safeRemoveOverlay(overlayPath) 611: logError(error instanceof Error ? error : new Error('Speculation failed')) 612: logSpeculation( 613: id, 614: 'error', 615: startTime, 616: suggestionText.length, 617: messagesRef.current, 618: null, 619: { 620: error_type: error instanceof Error ? error.name : 'Unknown', 621: error_message: errorMessage(error).slice( 622: 0, 623: 200, 624: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 625: error_phase: 626: 'start' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 627: is_pipelined: isPipelined, 628: }, 629: ) 630: resetSpeculationState(setAppState) 631: } 632: } 633: export async function acceptSpeculation( 634: state: SpeculationState, 635: setAppState: (f: (prev: AppState) => AppState) => void, 636: cleanMessageCount: number, 637: ): Promise<SpeculationResult | null> { 638: if (state.status !== 'active') return null 639: const { 640: id, 641: messagesRef, 642: writtenPathsRef, 643: abort, 644: startTime, 645: suggestionLength, 646: isPipelined, 647: } = state 648: const messages = messagesRef.current 649: const overlayPath = getOverlayPath(id) 650: const acceptedAt = Date.now() 651: abort() 652: if (cleanMessageCount > 0) { 653: await copyOverlayToMain(overlayPath, writtenPathsRef.current, getCwdState()) 654: } 655: safeRemoveOverlay(overlayPath) 656: let boundary: CompletionBoundary | null = state.boundary 657: let timeSavedMs = 658: Math.min(acceptedAt, boundary?.completedAt ?? Infinity) - startTime 659: setAppState(prev => { 660: if (prev.speculation.status === 'active' && prev.speculation.boundary) { 661: boundary = prev.speculation.boundary 662: const endTime = Math.min(acceptedAt, boundary.completedAt ?? Infinity) 663: timeSavedMs = endTime - startTime 664: } 665: return { 666: ...prev, 667: speculation: IDLE_SPECULATION_STATE, 668: speculationSessionTimeSavedMs: 669: prev.speculationSessionTimeSavedMs + timeSavedMs, 670: } 671: }) 672: logForDebugging( 673: boundary === null 674: ? `[Speculation] Accept ${id}: still running, using ${messages.length} messages` 675: : `[Speculation] Accept ${id}: already complete`, 676: ) 677: logSpeculation( 678: id, 679: 'accepted', 680: startTime, 681: suggestionLength, 682: messages, 683: boundary, 684: { 685: message_count: messages.length, 686: time_saved_ms: timeSavedMs, 687: is_pipelined: isPipelined, 688: }, 689: ) 690: if (timeSavedMs > 0) { 691: const entry: SpeculationAcceptMessage = { 692: type: 'speculation-accept', 693: timestamp: new Date().toISOString(), 694: timeSavedMs, 695: } 696: void appendFile(getTranscriptPath(), jsonStringify(entry) + '\n', { 697: mode: 0o600, 698: }).catch(() => { 699: logForDebugging( 700: '[Speculation] Failed to write speculation-accept to transcript', 701: ) 702: }) 703: } 704: return { messages, boundary, timeSavedMs } 705: } 706: export function abortSpeculation(setAppState: SetAppState): void { 707: setAppState(prev => { 708: if (prev.speculation.status !== 'active') return prev 709: const { 710: id, 711: abort, 712: startTime, 713: boundary, 714: suggestionLength, 715: messagesRef, 716: isPipelined, 717: } = prev.speculation 718: logForDebugging(`[Speculation] Aborting ${id}`) 719: logSpeculation( 720: id, 721: 'aborted', 722: startTime, 723: suggestionLength, 724: messagesRef.current, 725: boundary, 726: { abort_reason: 'user_typed', is_pipelined: isPipelined }, 727: ) 728: abort() 729: safeRemoveOverlay(getOverlayPath(id)) 730: return { ...prev, speculation: IDLE_SPECULATION_STATE } 731: }) 732: } 733: export async function handleSpeculationAccept( 734: speculationState: ActiveSpeculationState, 735: speculationSessionTimeSavedMs: number, 736: setAppState: SetAppState, 737: input: string, 738: deps: { 739: setMessages: (f: (prev: Message[]) => Message[]) => void 740: readFileState: { current: FileStateCache } 741: cwd: string 742: }, 743: ): Promise<{ queryRequired: boolean }> { 744: try { 745: const { setMessages, readFileState, cwd } = deps 746: setAppState(prev => { 747: if ( 748: prev.promptSuggestion.text === null && 749: prev.promptSuggestion.promptId === null 750: ) { 751: return prev 752: } 753: return { 754: ...prev, 755: promptSuggestion: { 756: text: null, 757: promptId: null, 758: shownAt: 0, 759: acceptedAt: 0, 760: generationRequestId: null, 761: }, 762: } 763: }) 764: const speculationMessages = speculationState.messagesRef.current 765: let cleanMessages = prepareMessagesForInjection(speculationMessages) 766: const userMessage = createUserMessage({ content: input }) 767: setMessages(prev => [...prev, userMessage]) 768: const result = await acceptSpeculation( 769: speculationState, 770: setAppState, 771: cleanMessages.length, 772: ) 773: const isComplete = result?.boundary?.type === 'complete' 774: if (!isComplete) { 775: const lastNonAssistant = cleanMessages.findLastIndex( 776: m => m.type !== 'assistant', 777: ) 778: cleanMessages = cleanMessages.slice(0, lastNonAssistant + 1) 779: } 780: const timeSavedMs = result?.timeSavedMs ?? 0 781: const newSessionTotal = speculationSessionTimeSavedMs + timeSavedMs 782: const feedbackMessage = createSpeculationFeedbackMessage( 783: cleanMessages, 784: result?.boundary ?? null, 785: timeSavedMs, 786: newSessionTotal, 787: ) 788: setMessages(prev => [...prev, ...cleanMessages]) 789: const extracted = extractReadFilesFromMessages( 790: cleanMessages, 791: cwd, 792: READ_FILE_STATE_CACHE_SIZE, 793: ) 794: readFileState.current = mergeFileStateCaches( 795: readFileState.current, 796: extracted, 797: ) 798: if (feedbackMessage) { 799: setMessages(prev => [...prev, feedbackMessage]) 800: } 801: logForDebugging( 802: `[Speculation] ${result?.boundary?.type ?? 'incomplete'}, injected ${cleanMessages.length} messages`, 803: ) 804: if (isComplete && speculationState.pipelinedSuggestion) { 805: const { text, promptId, generationRequestId } = 806: speculationState.pipelinedSuggestion 807: logForDebugging( 808: `[Speculation] Promoting pipelined suggestion: "${text.slice(0, 50)}..."`, 809: ) 810: setAppState(prev => ({ 811: ...prev, 812: promptSuggestion: { 813: text, 814: promptId, 815: shownAt: Date.now(), 816: acceptedAt: 0, 817: generationRequestId, 818: }, 819: })) 820: const augmentedContext: REPLHookContext = { 821: ...speculationState.contextRef.current, 822: messages: [ 823: ...speculationState.contextRef.current.messages, 824: createUserMessage({ content: input }), 825: ...cleanMessages, 826: ], 827: } 828: void startSpeculation(text, augmentedContext, setAppState, true) 829: } 830: return { queryRequired: !isComplete } 831: } catch (error) { 832: logError( 833: error instanceof Error 834: ? error 835: : new Error('handleSpeculationAccept failed'), 836: ) 837: logSpeculation( 838: speculationState.id, 839: 'error', 840: speculationState.startTime, 841: speculationState.suggestionLength, 842: speculationState.messagesRef.current, 843: speculationState.boundary, 844: { 845: error_type: error instanceof Error ? error.name : 'Unknown', 846: error_message: errorMessage(error).slice( 847: 0, 848: 200, 849: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 850: error_phase: 851: 'accept' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 852: is_pipelined: speculationState.isPipelined, 853: }, 854: ) 855: safeRemoveOverlay(getOverlayPath(speculationState.id)) 856: resetSpeculationState(setAppState) 857: return { queryRequired: true } 858: } 859: }

File: src/services/remoteManagedSettings/index.ts

typescript 1: import axios from 'axios' 2: import { createHash } from 'crypto' 3: import { open, unlink } from 'fs/promises' 4: import { getOauthConfig, OAUTH_BETA_HEADER } from '../../constants/oauth.js' 5: import { 6: checkAndRefreshOAuthTokenIfNeeded, 7: getAnthropicApiKeyWithSource, 8: getClaudeAIOAuthTokens, 9: } from '../../utils/auth.js' 10: import { registerCleanup } from '../../utils/cleanupRegistry.js' 11: import { logForDebugging } from '../../utils/debug.js' 12: import { classifyAxiosError, getErrnoCode } from '../../utils/errors.js' 13: import { settingsChangeDetector } from '../../utils/settings/changeDetector.js' 14: import { 15: type SettingsJson, 16: SettingsSchema, 17: } from '../../utils/settings/types.js' 18: import { sleep } from '../../utils/sleep.js' 19: import { jsonStringify } from '../../utils/slowOperations.js' 20: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 21: import { getRetryDelay } from '../api/withRetry.js' 22: import { 23: checkManagedSettingsSecurity, 24: handleSecurityCheckResult, 25: } from './securityCheck.jsx' 26: import { isRemoteManagedSettingsEligible, resetSyncCache } from './syncCache.js' 27: import { 28: getRemoteManagedSettingsSyncFromCache, 29: getSettingsPath, 30: setSessionCache, 31: } from './syncCacheState.js' 32: import { 33: type RemoteManagedSettingsFetchResult, 34: RemoteManagedSettingsResponseSchema, 35: } from './types.js' 36: const SETTINGS_TIMEOUT_MS = 10000 37: const DEFAULT_MAX_RETRIES = 5 38: const POLLING_INTERVAL_MS = 60 * 60 * 1000 39: let pollingIntervalId: ReturnType<typeof setInterval> | null = null 40: let loadingCompletePromise: Promise<void> | null = null 41: let loadingCompleteResolve: (() => void) | null = null 42: const LOADING_PROMISE_TIMEOUT_MS = 30000 43: export function initializeRemoteManagedSettingsLoadingPromise(): void { 44: if (loadingCompletePromise) { 45: return 46: } 47: if (isRemoteManagedSettingsEligible()) { 48: loadingCompletePromise = new Promise(resolve => { 49: loadingCompleteResolve = resolve 50: setTimeout(() => { 51: if (loadingCompleteResolve) { 52: logForDebugging( 53: 'Remote settings: Loading promise timed out, resolving anyway', 54: ) 55: loadingCompleteResolve() 56: loadingCompleteResolve = null 57: } 58: }, LOADING_PROMISE_TIMEOUT_MS) 59: }) 60: } 61: } 62: function getRemoteManagedSettingsEndpoint() { 63: return `${getOauthConfig().BASE_API_URL}/api/claude_code/settings` 64: } 65: function sortKeysDeep(obj: unknown): unknown { 66: if (Array.isArray(obj)) { 67: return obj.map(sortKeysDeep) 68: } 69: if (obj !== null && typeof obj === 'object') { 70: const sorted: Record<string, unknown> = {} 71: for (const key of Object.keys(obj).sort()) { 72: sorted[key] = sortKeysDeep((obj as Record<string, unknown>)[key]) 73: } 74: return sorted 75: } 76: return obj 77: } 78: export function computeChecksumFromSettings(settings: SettingsJson): string { 79: const sorted = sortKeysDeep(settings) 80: const normalized = jsonStringify(sorted) 81: const hash = createHash('sha256').update(normalized).digest('hex') 82: return `sha256:${hash}` 83: } 84: export function isEligibleForRemoteManagedSettings(): boolean { 85: return isRemoteManagedSettingsEligible() 86: } 87: export async function waitForRemoteManagedSettingsToLoad(): Promise<void> { 88: if (loadingCompletePromise) { 89: await loadingCompletePromise 90: } 91: } 92: function getRemoteSettingsAuthHeaders(): { 93: headers: Record<string, string> 94: error?: string 95: } { 96: try { 97: const { key: apiKey } = getAnthropicApiKeyWithSource({ 98: skipRetrievingKeyFromApiKeyHelper: true, 99: }) 100: if (apiKey) { 101: return { 102: headers: { 103: 'x-api-key': apiKey, 104: }, 105: } 106: } 107: } catch { 108: } 109: const oauthTokens = getClaudeAIOAuthTokens() 110: if (oauthTokens?.accessToken) { 111: return { 112: headers: { 113: Authorization: `Bearer ${oauthTokens.accessToken}`, 114: 'anthropic-beta': OAUTH_BETA_HEADER, 115: }, 116: } 117: } 118: return { 119: headers: {}, 120: error: 'No authentication available', 121: } 122: } 123: async function fetchWithRetry( 124: cachedChecksum?: string, 125: ): Promise<RemoteManagedSettingsFetchResult> { 126: let lastResult: RemoteManagedSettingsFetchResult | null = null 127: for (let attempt = 1; attempt <= DEFAULT_MAX_RETRIES + 1; attempt++) { 128: lastResult = await fetchRemoteManagedSettings(cachedChecksum) 129: if (lastResult.success) { 130: return lastResult 131: } 132: if (lastResult.skipRetry) { 133: return lastResult 134: } 135: if (attempt > DEFAULT_MAX_RETRIES) { 136: return lastResult 137: } 138: const delayMs = getRetryDelay(attempt) 139: logForDebugging( 140: `Remote settings: Retry ${attempt}/${DEFAULT_MAX_RETRIES} after ${delayMs}ms`, 141: ) 142: await sleep(delayMs) 143: } 144: return lastResult! 145: } 146: async function fetchRemoteManagedSettings( 147: cachedChecksum?: string, 148: ): Promise<RemoteManagedSettingsFetchResult> { 149: try { 150: await checkAndRefreshOAuthTokenIfNeeded() 151: const authHeaders = getRemoteSettingsAuthHeaders() 152: if (authHeaders.error) { 153: return { 154: success: false, 155: error: `Authentication required for remote settings`, 156: skipRetry: true, 157: } 158: } 159: const endpoint = getRemoteManagedSettingsEndpoint() 160: const headers: Record<string, string> = { 161: ...authHeaders.headers, 162: 'User-Agent': getClaudeCodeUserAgent(), 163: } 164: if (cachedChecksum) { 165: headers['If-None-Match'] = `"${cachedChecksum}"` 166: } 167: const response = await axios.get(endpoint, { 168: headers, 169: timeout: SETTINGS_TIMEOUT_MS, 170: validateStatus: status => 171: status === 200 || status === 204 || status === 304 || status === 404, 172: }) 173: if (response.status === 304) { 174: logForDebugging('Remote settings: Using cached settings (304)') 175: return { 176: success: true, 177: settings: null, 178: checksum: cachedChecksum, 179: } 180: } 181: if (response.status === 204 || response.status === 404) { 182: logForDebugging(`Remote settings: No settings found (${response.status})`) 183: return { 184: success: true, 185: settings: {}, 186: checksum: undefined, 187: } 188: } 189: const parsed = RemoteManagedSettingsResponseSchema().safeParse( 190: response.data, 191: ) 192: if (!parsed.success) { 193: logForDebugging( 194: `Remote settings: Invalid response format - ${parsed.error.message}`, 195: ) 196: return { 197: success: false, 198: error: 'Invalid remote settings format', 199: } 200: } 201: const settingsValidation = SettingsSchema().safeParse(parsed.data.settings) 202: if (!settingsValidation.success) { 203: logForDebugging( 204: `Remote settings: Settings validation failed - ${settingsValidation.error.message}`, 205: ) 206: return { 207: success: false, 208: error: 'Invalid settings structure', 209: } 210: } 211: logForDebugging('Remote settings: Fetched successfully') 212: return { 213: success: true, 214: settings: settingsValidation.data, 215: checksum: parsed.data.checksum, 216: } 217: } catch (error) { 218: const { kind, status, message } = classifyAxiosError(error) 219: if (status === 404) { 220: return { success: true, settings: {}, checksum: '' } 221: } 222: switch (kind) { 223: case 'auth': 224: return { 225: success: false, 226: error: 'Not authorized for remote settings', 227: skipRetry: true, 228: } 229: case 'timeout': 230: return { success: false, error: 'Remote settings request timeout' } 231: case 'network': 232: return { success: false, error: 'Cannot connect to server' } 233: default: 234: return { success: false, error: message } 235: } 236: } 237: } 238: async function saveSettings(settings: SettingsJson): Promise<void> { 239: try { 240: const path = getSettingsPath() 241: const handle = await open(path, 'w', 0o600) 242: try { 243: await handle.writeFile(jsonStringify(settings, null, 2), { 244: encoding: 'utf-8', 245: }) 246: await handle.datasync() 247: } finally { 248: await handle.close() 249: } 250: logForDebugging(`Remote settings: Saved to ${path}`) 251: } catch (error) { 252: logForDebugging( 253: `Remote settings: Failed to save - ${error instanceof Error ? error.message : 'unknown error'}`, 254: ) 255: } 256: } 257: export async function clearRemoteManagedSettingsCache(): Promise<void> { 258: stopBackgroundPolling() 259: resetSyncCache() 260: loadingCompletePromise = null 261: loadingCompleteResolve = null 262: try { 263: const path = getSettingsPath() 264: await unlink(path) 265: } catch { 266: } 267: } 268: async function fetchAndLoadRemoteManagedSettings(): Promise<SettingsJson | null> { 269: if (!isRemoteManagedSettingsEligible()) { 270: return null 271: } 272: const cachedSettings = getRemoteManagedSettingsSyncFromCache() 273: const cachedChecksum = cachedSettings 274: ? computeChecksumFromSettings(cachedSettings) 275: : undefined 276: try { 277: const result = await fetchWithRetry(cachedChecksum) 278: if (!result.success) { 279: if (cachedSettings) { 280: logForDebugging( 281: 'Remote settings: Using stale cache after fetch failure', 282: ) 283: setSessionCache(cachedSettings) 284: return cachedSettings 285: } 286: return null 287: } 288: if (result.settings === null && cachedSettings) { 289: logForDebugging('Remote settings: Cache still valid (304 Not Modified)') 290: setSessionCache(cachedSettings) 291: return cachedSettings 292: } 293: const newSettings = result.settings || {} 294: const hasContent = Object.keys(newSettings).length > 0 295: if (hasContent) { 296: const securityResult = await checkManagedSettingsSecurity( 297: cachedSettings, 298: newSettings, 299: ) 300: if (!handleSecurityCheckResult(securityResult)) { 301: logForDebugging( 302: 'Remote settings: User rejected new settings, using cached settings', 303: ) 304: return cachedSettings 305: } 306: setSessionCache(newSettings) 307: await saveSettings(newSettings) 308: logForDebugging('Remote settings: Applied new settings successfully') 309: return newSettings 310: } 311: setSessionCache(newSettings) 312: try { 313: const path = getSettingsPath() 314: await unlink(path) 315: logForDebugging('Remote settings: Deleted cached file (404 response)') 316: } catch (e) { 317: const code = getErrnoCode(e) 318: if (code !== 'ENOENT') { 319: logForDebugging( 320: `Remote settings: Failed to delete cached file - ${e instanceof Error ? e.message : 'unknown error'}`, 321: ) 322: } 323: } 324: return newSettings 325: } catch { 326: if (cachedSettings) { 327: logForDebugging('Remote settings: Using stale cache after error') 328: setSessionCache(cachedSettings) 329: return cachedSettings 330: } 331: return null 332: } 333: } 334: export async function loadRemoteManagedSettings(): Promise<void> { 335: if (isRemoteManagedSettingsEligible() && !loadingCompletePromise) { 336: loadingCompletePromise = new Promise(resolve => { 337: loadingCompleteResolve = resolve 338: }) 339: } 340: if (getRemoteManagedSettingsSyncFromCache() && loadingCompleteResolve) { 341: loadingCompleteResolve() 342: loadingCompleteResolve = null 343: } 344: try { 345: const settings = await fetchAndLoadRemoteManagedSettings() 346: if (isRemoteManagedSettingsEligible()) { 347: startBackgroundPolling() 348: } 349: if (settings !== null) { 350: settingsChangeDetector.notifyChange('policySettings') 351: } 352: } finally { 353: if (loadingCompleteResolve) { 354: loadingCompleteResolve() 355: loadingCompleteResolve = null 356: } 357: } 358: } 359: export async function refreshRemoteManagedSettings(): Promise<void> { 360: await clearRemoteManagedSettingsCache() 361: if (!isRemoteManagedSettingsEligible()) { 362: settingsChangeDetector.notifyChange('policySettings') 363: return 364: } 365: await fetchAndLoadRemoteManagedSettings() 366: logForDebugging('Remote settings: Refreshed after auth change') 367: settingsChangeDetector.notifyChange('policySettings') 368: } 369: async function pollRemoteSettings(): Promise<void> { 370: if (!isRemoteManagedSettingsEligible()) { 371: return 372: } 373: const prevCache = getRemoteManagedSettingsSyncFromCache() 374: const previousSettings = prevCache ? jsonStringify(prevCache) : null 375: try { 376: await fetchAndLoadRemoteManagedSettings() 377: const newCache = getRemoteManagedSettingsSyncFromCache() 378: const newSettings = newCache ? jsonStringify(newCache) : null 379: if (newSettings !== previousSettings) { 380: logForDebugging('Remote settings: Changed during background poll') 381: settingsChangeDetector.notifyChange('policySettings') 382: } 383: } catch { 384: } 385: } 386: export function startBackgroundPolling(): void { 387: if (pollingIntervalId !== null) { 388: return 389: } 390: if (!isRemoteManagedSettingsEligible()) { 391: return 392: } 393: pollingIntervalId = setInterval(() => { 394: void pollRemoteSettings() 395: }, POLLING_INTERVAL_MS) 396: pollingIntervalId.unref() 397: registerCleanup(async () => stopBackgroundPolling()) 398: } 399: export function stopBackgroundPolling(): void { 400: if (pollingIntervalId !== null) { 401: clearInterval(pollingIntervalId) 402: pollingIntervalId = null 403: } 404: }

File: src/services/remoteManagedSettings/securityCheck.tsx

typescript 1: import React from 'react'; 2: import { getIsInteractive } from '../../bootstrap/state.js'; 3: import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'; 4: import { extractDangerousSettings, hasDangerousSettings, hasDangerousSettingsChanged } from '../../components/ManagedSettingsSecurityDialog/utils.js'; 5: import { render } from '../../ink.js'; 6: import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; 7: import { AppStateProvider } from '../../state/AppState.js'; 8: import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; 9: import { getBaseRenderOptions } from '../../utils/renderOptions.js'; 10: import type { SettingsJson } from '../../utils/settings/types.js'; 11: import { logEvent } from '../analytics/index.js'; 12: export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; 13: export async function checkManagedSettingsSecurity(cachedSettings: SettingsJson | null, newSettings: SettingsJson | null): Promise<SecurityCheckResult> { 14: if (!newSettings || !hasDangerousSettings(extractDangerousSettings(newSettings))) { 15: return 'no_check_needed'; 16: } 17: if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) { 18: return 'no_check_needed'; 19: } 20: if (!getIsInteractive()) { 21: return 'no_check_needed'; 22: } 23: logEvent('tengu_managed_settings_security_dialog_shown', {}); 24: return new Promise<SecurityCheckResult>(resolve => { 25: void (async () => { 26: const { 27: unmount 28: } = await render(<AppStateProvider> 29: <KeybindingSetup> 30: <ManagedSettingsSecurityDialog settings={newSettings} onAccept={() => { 31: logEvent('tengu_managed_settings_security_dialog_accepted', {}); 32: unmount(); 33: void resolve('approved'); 34: }} onReject={() => { 35: logEvent('tengu_managed_settings_security_dialog_rejected', {}); 36: unmount(); 37: void resolve('rejected'); 38: }} /> 39: </KeybindingSetup> 40: </AppStateProvider>, getBaseRenderOptions(false)); 41: })(); 42: }); 43: } 44: export function handleSecurityCheckResult(result: SecurityCheckResult): boolean { 45: if (result === 'rejected') { 46: gracefulShutdownSync(1); 47: return false; 48: } 49: return true; 50: }

File: src/services/remoteManagedSettings/syncCache.ts

typescript 1: import { CLAUDE_AI_INFERENCE_SCOPE } from '../../constants/oauth.js' 2: import { 3: getAnthropicApiKeyWithSource, 4: getClaudeAIOAuthTokens, 5: } from '../../utils/auth.js' 6: import { 7: getAPIProvider, 8: isFirstPartyAnthropicBaseUrl, 9: } from '../../utils/model/providers.js' 10: import { 11: resetSyncCache as resetLeafCache, 12: setEligibility, 13: } from './syncCacheState.js' 14: let cached: boolean | undefined 15: export function resetSyncCache(): void { 16: cached = undefined 17: resetLeafCache() 18: } 19: export function isRemoteManagedSettingsEligible(): boolean { 20: if (cached !== undefined) return cached 21: if (getAPIProvider() !== 'firstParty') { 22: return (cached = setEligibility(false)) 23: } 24: if (!isFirstPartyAnthropicBaseUrl()) { 25: return (cached = setEligibility(false)) 26: } 27: if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') { 28: return (cached = setEligibility(false)) 29: } 30: const tokens = getClaudeAIOAuthTokens() 31: if (tokens?.accessToken && tokens.subscriptionType === null) { 32: return (cached = setEligibility(true)) 33: } 34: if ( 35: tokens?.accessToken && 36: tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE) && 37: (tokens.subscriptionType === 'enterprise' || 38: tokens.subscriptionType === 'team') 39: ) { 40: return (cached = setEligibility(true)) 41: } 42: try { 43: const { key: apiKey } = getAnthropicApiKeyWithSource({ 44: skipRetrievingKeyFromApiKeyHelper: true, 45: }) 46: if (apiKey) { 47: return (cached = setEligibility(true)) 48: } 49: } catch { 50: } 51: return (cached = setEligibility(false)) 52: }

File: src/services/remoteManagedSettings/syncCacheState.ts

typescript 1: import { join } from 'path' 2: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 3: import { readFileSync } from '../../utils/fileRead.js' 4: import { stripBOM } from '../../utils/jsonRead.js' 5: import { resetSettingsCache } from '../../utils/settings/settingsCache.js' 6: import type { SettingsJson } from '../../utils/settings/types.js' 7: import { jsonParse } from '../../utils/slowOperations.js' 8: const SETTINGS_FILENAME = 'remote-settings.json' 9: let sessionCache: SettingsJson | null = null 10: let eligible: boolean | undefined 11: export function setSessionCache(value: SettingsJson | null): void { 12: sessionCache = value 13: } 14: export function resetSyncCache(): void { 15: sessionCache = null 16: eligible = undefined 17: } 18: export function setEligibility(v: boolean): boolean { 19: eligible = v 20: return v 21: } 22: export function getSettingsPath(): string { 23: return join(getClaudeConfigHomeDir(), SETTINGS_FILENAME) 24: } 25: function loadSettings(): SettingsJson | null { 26: try { 27: const content = readFileSync(getSettingsPath()) 28: const data: unknown = jsonParse(stripBOM(content)) 29: if (!data || typeof data !== 'object' || Array.isArray(data)) { 30: return null 31: } 32: return data as SettingsJson 33: } catch { 34: return null 35: } 36: } 37: export function getRemoteManagedSettingsSyncFromCache(): SettingsJson | null { 38: if (eligible !== true) return null 39: if (sessionCache) return sessionCache 40: const cachedSettings = loadSettings() 41: if (cachedSettings) { 42: sessionCache = cachedSettings 43: resetSettingsCache() 44: return cachedSettings 45: } 46: return null 47: }

File: src/services/remoteManagedSettings/types.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: import type { SettingsJson } from '../../utils/settings/types.js' 4: export const RemoteManagedSettingsResponseSchema = lazySchema(() => 5: z.object({ 6: uuid: z.string(), 7: checksum: z.string(), 8: settings: z.record(z.string(), z.unknown()) as z.ZodType<SettingsJson>, 9: }), 10: ) 11: export type RemoteManagedSettingsResponse = z.infer< 12: ReturnType<typeof RemoteManagedSettingsResponseSchema> 13: > 14: export type RemoteManagedSettingsFetchResult = { 15: success: boolean 16: settings?: SettingsJson | null 17: checksum?: string 18: error?: string 19: skipRetry?: boolean 20: }

File: src/services/SessionMemory/prompts.ts

typescript 1: import { readFile } from 'fs/promises' 2: import { join } from 'path' 3: import { roughTokenCountEstimation } from '../../services/tokenEstimation.js' 4: import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' 5: import { getErrnoCode, toError } from '../../utils/errors.js' 6: import { logError } from '../../utils/log.js' 7: const MAX_SECTION_LENGTH = 2000 8: const MAX_TOTAL_SESSION_MEMORY_TOKENS = 12000 9: export const DEFAULT_SESSION_MEMORY_TEMPLATE = ` 10: # Session Title 11: _A short and distinctive 5-10 word descriptive title for the session. Super info dense, no filler_ 12: # Current State 13: _What is actively being worked on right now? Pending tasks not yet completed. Immediate next steps._ 14: # Task specification 15: _What did the user ask to build? Any design decisions or other explanatory context_ 16: # Files and Functions 17: _What are the important files? In short, what do they contain and why are they relevant?_ 18: # Workflow 19: _What bash commands are usually run and in what order? How to interpret their output if not obvious?_ 20: # Errors & Corrections 21: _Errors encountered and how they were fixed. What did the user correct? What approaches failed and should not be tried again?_ 22: # Codebase and System Documentation 23: _What are the important system components? How do they work/fit together?_ 24: # Learnings 25: _What has worked well? What has not? What to avoid? Do not duplicate items from other sections_ 26: # Key results 27: _If the user asked a specific output such as an answer to a question, a table, or other document, repeat the exact result here_ 28: # Worklog 29: _Step by step, what was attempted, done? Very terse summary for each step_ 30: ` 31: function getDefaultUpdatePrompt(): string { 32: return `IMPORTANT: This message and these instructions are NOT part of the actual user conversation. Do NOT include any references to "note-taking", "session notes extraction", or these update instructions in the notes content. 33: Based on the user conversation above (EXCLUDING this note-taking instruction message as well as system prompt, claude.md entries, or any past session summaries), update the session notes file. 34: The file {{notesPath}} has already been read for you. Here are its current contents: 35: <current_notes_content> 36: {{currentNotes}} 37: </current_notes_content> 38: Your ONLY task is to use the Edit tool to update the notes file, then stop. You can make multiple edits (update every section as needed) - make all Edit tool calls in parallel in a single message. Do not call any other tools. 39: CRITICAL RULES FOR EDITING: 40: - The file must maintain its exact structure with all sections, headers, and italic descriptions intact 41: -- NEVER modify, delete, or add section headers (the lines starting with '#' like # Task specification) 42: -- NEVER modify or delete the italic _section description_ lines (these are the lines in italics immediately following each header - they start and end with underscores) 43: -- The italic _section descriptions_ are TEMPLATE INSTRUCTIONS that must be preserved exactly as-is - they guide what content belongs in each section 44: -- ONLY update the actual content that appears BELOW the italic _section descriptions_ within each existing section 45: -- Do NOT add any new sections, summaries, or information outside the existing structure 46: - Do NOT reference this note-taking process or instructions anywhere in the notes 47: - It's OK to skip updating a section if there are no substantial new insights to add. Do not add filler content like "No info yet", just leave sections blank/unedited if appropriate. 48: - Write DETAILED, INFO-DENSE content for each section - include specifics like file paths, function names, error messages, exact commands, technical details, etc. 49: - For "Key results", include the complete, exact output the user requested (e.g., full table, full answer, etc.) 50: - Do not include information that's already in the CLAUDE.md files included in the context 51: - Keep each section under ~${MAX_SECTION_LENGTH} tokens/words - if a section is approaching this limit, condense it by cycling out less important details while preserving the most critical information 52: - Focus on actionable, specific information that would help someone understand or recreate the work discussed in the conversation 53: - IMPORTANT: Always update "Current State" to reflect the most recent work - this is critical for continuity after compaction 54: Use the Edit tool with file_path: {{notesPath}} 55: STRUCTURE PRESERVATION REMINDER: 56: Each section has TWO parts that must be preserved exactly as they appear in the current file: 57: 1. The section header (line starting with #) 58: 2. The italic description line (the _italicized text_ immediately after the header - this is a template instruction) 59: You ONLY update the actual content that comes AFTER these two preserved lines. The italic description lines starting and ending with underscores are part of the template structure, NOT content to be edited or removed. 60: REMEMBER: Use the Edit tool in parallel and stop. Do not continue after the edits. Only include insights from the actual user conversation, never from these note-taking instructions. Do not delete or change section headers or italic _section descriptions_.` 61: } 62: export async function loadSessionMemoryTemplate(): Promise<string> { 63: const templatePath = join( 64: getClaudeConfigHomeDir(), 65: 'session-memory', 66: 'config', 67: 'template.md', 68: ) 69: try { 70: return await readFile(templatePath, { encoding: 'utf-8' }) 71: } catch (e: unknown) { 72: const code = getErrnoCode(e) 73: if (code === 'ENOENT') { 74: return DEFAULT_SESSION_MEMORY_TEMPLATE 75: } 76: logError(toError(e)) 77: return DEFAULT_SESSION_MEMORY_TEMPLATE 78: } 79: } 80: export async function loadSessionMemoryPrompt(): Promise<string> { 81: const promptPath = join( 82: getClaudeConfigHomeDir(), 83: 'session-memory', 84: 'config', 85: 'prompt.md', 86: ) 87: try { 88: return await readFile(promptPath, { encoding: 'utf-8' }) 89: } catch (e: unknown) { 90: const code = getErrnoCode(e) 91: if (code === 'ENOENT') { 92: return getDefaultUpdatePrompt() 93: } 94: logError(toError(e)) 95: return getDefaultUpdatePrompt() 96: } 97: } 98: function analyzeSectionSizes(content: string): Record<string, number> { 99: const sections: Record<string, number> = {} 100: const lines = content.split('\n') 101: let currentSection = '' 102: let currentContent: string[] = [] 103: for (const line of lines) { 104: if (line.startsWith('# ')) { 105: if (currentSection && currentContent.length > 0) { 106: const sectionContent = currentContent.join('\n').trim() 107: sections[currentSection] = roughTokenCountEstimation(sectionContent) 108: } 109: currentSection = line 110: currentContent = [] 111: } else { 112: currentContent.push(line) 113: } 114: } 115: if (currentSection && currentContent.length > 0) { 116: const sectionContent = currentContent.join('\n').trim() 117: sections[currentSection] = roughTokenCountEstimation(sectionContent) 118: } 119: return sections 120: } 121: function generateSectionReminders( 122: sectionSizes: Record<string, number>, 123: totalTokens: number, 124: ): string { 125: const overBudget = totalTokens > MAX_TOTAL_SESSION_MEMORY_TOKENS 126: const oversizedSections = Object.entries(sectionSizes) 127: .filter(([_, tokens]) => tokens > MAX_SECTION_LENGTH) 128: .sort(([, a], [, b]) => b - a) 129: .map( 130: ([section, tokens]) => 131: `- "${section}" is ~${tokens} tokens (limit: ${MAX_SECTION_LENGTH})`, 132: ) 133: if (oversizedSections.length === 0 && !overBudget) { 134: return '' 135: } 136: const parts: string[] = [] 137: if (overBudget) { 138: parts.push( 139: `\n\nCRITICAL: The session memory file is currently ~${totalTokens} tokens, which exceeds the maximum of ${MAX_TOTAL_SESSION_MEMORY_TOKENS} tokens. You MUST condense the file to fit within this budget. Aggressively shorten oversized sections by removing less important details, merging related items, and summarizing older entries. Prioritize keeping "Current State" and "Errors & Corrections" accurate and detailed.`, 140: ) 141: } 142: if (oversizedSections.length > 0) { 143: parts.push( 144: `\n\n${overBudget ? 'Oversized sections to condense' : 'IMPORTANT: The following sections exceed the per-section limit and MUST be condensed'}:\n${oversizedSections.join('\n')}`, 145: ) 146: } 147: return parts.join('') 148: } 149: /** 150: * Substitute variables in the prompt template using {{variable}} syntax 151: */ 152: function substituteVariables( 153: template: string, 154: variables: Record<string, string>, 155: ): string { 156: // Single-pass replacement avoids two bugs: (1) $ backreference corruption 157: // (replacer fn treats $ literally), and (2) double-substitution when user 158: // content happens to contain {{varName}} matching a later variable. 159: return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => 160: Object.prototype.hasOwnProperty.call(variables, key) 161: ? variables[key]! 162: : match, 163: ) 164: } 165: /** 166: * Check if the session memory content is essentially empty (matches the template). 167: * This is used to detect if no actual content has been extracted yet, 168: * which means we should fall back to legacy compact behavior. 169: */ 170: export async function isSessionMemoryEmpty(content: string): Promise<boolean> { 171: const template = await loadSessionMemoryTemplate() 172: // Compare trimmed content to detect if it's just the template 173: return content.trim() === template.trim() 174: } 175: export async function buildSessionMemoryUpdatePrompt( 176: currentNotes: string, 177: notesPath: string, 178: ): Promise<string> { 179: const promptTemplate = await loadSessionMemoryPrompt() 180: const sectionSizes = analyzeSectionSizes(currentNotes) 181: const totalTokens = roughTokenCountEstimation(currentNotes) 182: const sectionReminders = generateSectionReminders(sectionSizes, totalTokens) 183: const variables = { 184: currentNotes, 185: notesPath, 186: } 187: const basePrompt = substituteVariables(promptTemplate, variables) 188: return basePrompt + sectionReminders 189: } 190: export function truncateSessionMemoryForCompact(content: string): { 191: truncatedContent: string 192: wasTruncated: boolean 193: } { 194: const lines = content.split('\n') 195: const maxCharsPerSection = MAX_SECTION_LENGTH * 4 196: const outputLines: string[] = [] 197: let currentSectionLines: string[] = [] 198: let currentSectionHeader = '' 199: let wasTruncated = false 200: for (const line of lines) { 201: if (line.startsWith('# ')) { 202: const result = flushSessionSection( 203: currentSectionHeader, 204: currentSectionLines, 205: maxCharsPerSection, 206: ) 207: outputLines.push(...result.lines) 208: wasTruncated = wasTruncated || result.wasTruncated 209: currentSectionHeader = line 210: currentSectionLines = [] 211: } else { 212: currentSectionLines.push(line) 213: } 214: } 215: // Flush the last section 216: const result = flushSessionSection( 217: currentSectionHeader, 218: currentSectionLines, 219: maxCharsPerSection, 220: ) 221: outputLines.push(...result.lines) 222: wasTruncated = wasTruncated || result.wasTruncated 223: return { 224: truncatedContent: outputLines.join('\n'), 225: wasTruncated, 226: } 227: } 228: function flushSessionSection( 229: sectionHeader: string, 230: sectionLines: string[], 231: maxCharsPerSection: number, 232: ): { lines: string[]; wasTruncated: boolean } { 233: if (!sectionHeader) { 234: return { lines: sectionLines, wasTruncated: false } 235: } 236: const sectionContent = sectionLines.join('\n') 237: if (sectionContent.length <= maxCharsPerSection) { 238: return { lines: [sectionHeader, ...sectionLines], wasTruncated: false } 239: } 240: let charCount = 0 241: const keptLines: string[] = [sectionHeader] 242: for (const line of sectionLines) { 243: if (charCount + line.length + 1 > maxCharsPerSection) { 244: break 245: } 246: keptLines.push(line) 247: charCount += line.length + 1 248: } 249: keptLines.push('\n[... section truncated for length ...]') 250: return { lines: keptLines, wasTruncated: true } 251: }

File: src/services/SessionMemory/sessionMemory.ts

typescript 1: import { writeFile } from 'fs/promises' 2: import memoize from 'lodash-es/memoize.js' 3: import { getIsRemoteMode } from '../../bootstrap/state.js' 4: import { getSystemPrompt } from '../../constants/prompts.js' 5: import { getSystemContext, getUserContext } from '../../context.js' 6: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 7: import type { Tool, ToolUseContext } from '../../Tool.js' 8: import { FILE_EDIT_TOOL_NAME } from '../../tools/FileEditTool/constants.js' 9: import { 10: FileReadTool, 11: type Output as FileReadToolOutput, 12: } from '../../tools/FileReadTool/FileReadTool.js' 13: import type { Message } from '../../types/message.js' 14: import { count } from '../../utils/array.js' 15: import { 16: createCacheSafeParams, 17: createSubagentContext, 18: runForkedAgent, 19: } from '../../utils/forkedAgent.js' 20: import { getFsImplementation } from '../../utils/fsOperations.js' 21: import { 22: type REPLHookContext, 23: registerPostSamplingHook, 24: } from '../../utils/hooks/postSamplingHooks.js' 25: import { 26: createUserMessage, 27: hasToolCallsInLastAssistantTurn, 28: } from '../../utils/messages.js' 29: import { 30: getSessionMemoryDir, 31: getSessionMemoryPath, 32: } from '../../utils/permissions/filesystem.js' 33: import { sequential } from '../../utils/sequential.js' 34: import { asSystemPrompt } from '../../utils/systemPromptType.js' 35: import { getTokenUsage, tokenCountWithEstimation } from '../../utils/tokens.js' 36: import { logEvent } from '../analytics/index.js' 37: import { isAutoCompactEnabled } from '../compact/autoCompact.js' 38: import { 39: buildSessionMemoryUpdatePrompt, 40: loadSessionMemoryTemplate, 41: } from './prompts.js' 42: import { 43: DEFAULT_SESSION_MEMORY_CONFIG, 44: getSessionMemoryConfig, 45: getToolCallsBetweenUpdates, 46: hasMetInitializationThreshold, 47: hasMetUpdateThreshold, 48: isSessionMemoryInitialized, 49: markExtractionCompleted, 50: markExtractionStarted, 51: markSessionMemoryInitialized, 52: recordExtractionTokenCount, 53: type SessionMemoryConfig, 54: setLastSummarizedMessageId, 55: setSessionMemoryConfig, 56: } from './sessionMemoryUtils.js' 57: import { errorMessage, getErrnoCode } from '../../utils/errors.js' 58: import { 59: getDynamicConfig_CACHED_MAY_BE_STALE, 60: getFeatureValue_CACHED_MAY_BE_STALE, 61: } from '../analytics/growthbook.js' 62: function isSessionMemoryGateEnabled(): boolean { 63: return getFeatureValue_CACHED_MAY_BE_STALE('tengu_session_memory', false) 64: } 65: function getSessionMemoryRemoteConfig(): Partial<SessionMemoryConfig> { 66: return getDynamicConfig_CACHED_MAY_BE_STALE<Partial<SessionMemoryConfig>>( 67: 'tengu_sm_config', 68: {}, 69: ) 70: } 71: let lastMemoryMessageUuid: string | undefined 72: export function resetLastMemoryMessageUuid(): void { 73: lastMemoryMessageUuid = undefined 74: } 75: function countToolCallsSince( 76: messages: Message[], 77: sinceUuid: string | undefined, 78: ): number { 79: let toolCallCount = 0 80: let foundStart = sinceUuid === null || sinceUuid === undefined 81: for (const message of messages) { 82: if (!foundStart) { 83: if (message.uuid === sinceUuid) { 84: foundStart = true 85: } 86: continue 87: } 88: if (message.type === 'assistant') { 89: const content = message.message.content 90: if (Array.isArray(content)) { 91: toolCallCount += count(content, block => block.type === 'tool_use') 92: } 93: } 94: } 95: return toolCallCount 96: } 97: export function shouldExtractMemory(messages: Message[]): boolean { 98: const currentTokenCount = tokenCountWithEstimation(messages) 99: if (!isSessionMemoryInitialized()) { 100: if (!hasMetInitializationThreshold(currentTokenCount)) { 101: return false 102: } 103: markSessionMemoryInitialized() 104: } 105: const hasMetTokenThreshold = hasMetUpdateThreshold(currentTokenCount) 106: const toolCallsSinceLastUpdate = countToolCallsSince( 107: messages, 108: lastMemoryMessageUuid, 109: ) 110: const hasMetToolCallThreshold = 111: toolCallsSinceLastUpdate >= getToolCallsBetweenUpdates() 112: const hasToolCallsInLastTurn = hasToolCallsInLastAssistantTurn(messages) 113: const shouldExtract = 114: (hasMetTokenThreshold && hasMetToolCallThreshold) || 115: (hasMetTokenThreshold && !hasToolCallsInLastTurn) 116: if (shouldExtract) { 117: const lastMessage = messages[messages.length - 1] 118: if (lastMessage?.uuid) { 119: lastMemoryMessageUuid = lastMessage.uuid 120: } 121: return true 122: } 123: return false 124: } 125: async function setupSessionMemoryFile( 126: toolUseContext: ToolUseContext, 127: ): Promise<{ memoryPath: string; currentMemory: string }> { 128: const fs = getFsImplementation() 129: const sessionMemoryDir = getSessionMemoryDir() 130: await fs.mkdir(sessionMemoryDir, { mode: 0o700 }) 131: const memoryPath = getSessionMemoryPath() 132: try { 133: await writeFile(memoryPath, '', { 134: encoding: 'utf-8', 135: mode: 0o600, 136: flag: 'wx', 137: }) 138: const template = await loadSessionMemoryTemplate() 139: await writeFile(memoryPath, template, { 140: encoding: 'utf-8', 141: mode: 0o600, 142: }) 143: } catch (e: unknown) { 144: const code = getErrnoCode(e) 145: if (code !== 'EEXIST') { 146: throw e 147: } 148: } 149: toolUseContext.readFileState.delete(memoryPath) 150: const result = await FileReadTool.call( 151: { file_path: memoryPath }, 152: toolUseContext, 153: ) 154: let currentMemory = '' 155: const output = result.data as FileReadToolOutput 156: if (output.type === 'text') { 157: currentMemory = output.file.content 158: } 159: logEvent('tengu_session_memory_file_read', { 160: content_length: currentMemory.length, 161: }) 162: return { memoryPath, currentMemory } 163: } 164: const initSessionMemoryConfigIfNeeded = memoize((): void => { 165: const remoteConfig = getSessionMemoryRemoteConfig() 166: const config: SessionMemoryConfig = { 167: minimumMessageTokensToInit: 168: remoteConfig.minimumMessageTokensToInit && 169: remoteConfig.minimumMessageTokensToInit > 0 170: ? remoteConfig.minimumMessageTokensToInit 171: : DEFAULT_SESSION_MEMORY_CONFIG.minimumMessageTokensToInit, 172: minimumTokensBetweenUpdate: 173: remoteConfig.minimumTokensBetweenUpdate && 174: remoteConfig.minimumTokensBetweenUpdate > 0 175: ? remoteConfig.minimumTokensBetweenUpdate 176: : DEFAULT_SESSION_MEMORY_CONFIG.minimumTokensBetweenUpdate, 177: toolCallsBetweenUpdates: 178: remoteConfig.toolCallsBetweenUpdates && 179: remoteConfig.toolCallsBetweenUpdates > 0 180: ? remoteConfig.toolCallsBetweenUpdates 181: : DEFAULT_SESSION_MEMORY_CONFIG.toolCallsBetweenUpdates, 182: } 183: setSessionMemoryConfig(config) 184: }) 185: let hasLoggedGateFailure = false 186: const extractSessionMemory = sequential(async function ( 187: context: REPLHookContext, 188: ): Promise<void> { 189: const { messages, toolUseContext, querySource } = context 190: if (querySource !== 'repl_main_thread') { 191: return 192: } 193: if (!isSessionMemoryGateEnabled()) { 194: if (process.env.USER_TYPE === 'ant' && !hasLoggedGateFailure) { 195: hasLoggedGateFailure = true 196: logEvent('tengu_session_memory_gate_disabled', {}) 197: } 198: return 199: } 200: initSessionMemoryConfigIfNeeded() 201: if (!shouldExtractMemory(messages)) { 202: return 203: } 204: markExtractionStarted() 205: const setupContext = createSubagentContext(toolUseContext) 206: const { memoryPath, currentMemory } = 207: await setupSessionMemoryFile(setupContext) 208: const userPrompt = await buildSessionMemoryUpdatePrompt( 209: currentMemory, 210: memoryPath, 211: ) 212: await runForkedAgent({ 213: promptMessages: [createUserMessage({ content: userPrompt })], 214: cacheSafeParams: createCacheSafeParams(context), 215: canUseTool: createMemoryFileCanUseTool(memoryPath), 216: querySource: 'session_memory', 217: forkLabel: 'session_memory', 218: overrides: { readFileState: setupContext.readFileState }, 219: }) 220: const lastMessage = messages[messages.length - 1] 221: const usage = lastMessage ? getTokenUsage(lastMessage) : undefined 222: const config = getSessionMemoryConfig() 223: logEvent('tengu_session_memory_extraction', { 224: input_tokens: usage?.input_tokens, 225: output_tokens: usage?.output_tokens, 226: cache_read_input_tokens: usage?.cache_read_input_tokens ?? undefined, 227: cache_creation_input_tokens: 228: usage?.cache_creation_input_tokens ?? undefined, 229: config_min_message_tokens_to_init: config.minimumMessageTokensToInit, 230: config_min_tokens_between_update: config.minimumTokensBetweenUpdate, 231: config_tool_calls_between_updates: config.toolCallsBetweenUpdates, 232: }) 233: recordExtractionTokenCount(tokenCountWithEstimation(messages)) 234: updateLastSummarizedMessageIdIfSafe(messages) 235: markExtractionCompleted() 236: }) 237: export function initSessionMemory(): void { 238: if (getIsRemoteMode()) return 239: const autoCompactEnabled = isAutoCompactEnabled() 240: if (process.env.USER_TYPE === 'ant') { 241: logEvent('tengu_session_memory_init', { 242: auto_compact_enabled: autoCompactEnabled, 243: }) 244: } 245: if (!autoCompactEnabled) { 246: return 247: } 248: registerPostSamplingHook(extractSessionMemory) 249: } 250: export type ManualExtractionResult = { 251: success: boolean 252: memoryPath?: string 253: error?: string 254: } 255: export async function manuallyExtractSessionMemory( 256: messages: Message[], 257: toolUseContext: ToolUseContext, 258: ): Promise<ManualExtractionResult> { 259: if (messages.length === 0) { 260: return { success: false, error: 'No messages to summarize' } 261: } 262: markExtractionStarted() 263: try { 264: const setupContext = createSubagentContext(toolUseContext) 265: const { memoryPath, currentMemory } = 266: await setupSessionMemoryFile(setupContext) 267: const userPrompt = await buildSessionMemoryUpdatePrompt( 268: currentMemory, 269: memoryPath, 270: ) 271: const { tools, mainLoopModel } = toolUseContext.options 272: const [rawSystemPrompt, userContext, systemContext] = await Promise.all([ 273: getSystemPrompt(tools, mainLoopModel), 274: getUserContext(), 275: getSystemContext(), 276: ]) 277: const systemPrompt = asSystemPrompt(rawSystemPrompt) 278: await runForkedAgent({ 279: promptMessages: [createUserMessage({ content: userPrompt })], 280: cacheSafeParams: { 281: systemPrompt, 282: userContext, 283: systemContext, 284: toolUseContext: setupContext, 285: forkContextMessages: messages, 286: }, 287: canUseTool: createMemoryFileCanUseTool(memoryPath), 288: querySource: 'session_memory', 289: forkLabel: 'session_memory_manual', 290: overrides: { readFileState: setupContext.readFileState }, 291: }) 292: logEvent('tengu_session_memory_manual_extraction', {}) 293: recordExtractionTokenCount(tokenCountWithEstimation(messages)) 294: updateLastSummarizedMessageIdIfSafe(messages) 295: return { success: true, memoryPath } 296: } catch (error) { 297: return { 298: success: false, 299: error: errorMessage(error), 300: } 301: } finally { 302: markExtractionCompleted() 303: } 304: } 305: export function createMemoryFileCanUseTool(memoryPath: string): CanUseToolFn { 306: return async (tool: Tool, input: unknown) => { 307: if ( 308: tool.name === FILE_EDIT_TOOL_NAME && 309: typeof input === 'object' && 310: input !== null && 311: 'file_path' in input 312: ) { 313: const filePath = input.file_path 314: if (typeof filePath === 'string' && filePath === memoryPath) { 315: return { behavior: 'allow' as const, updatedInput: input } 316: } 317: } 318: return { 319: behavior: 'deny' as const, 320: message: `only ${FILE_EDIT_TOOL_NAME} on ${memoryPath} is allowed`, 321: decisionReason: { 322: type: 'other' as const, 323: reason: `only ${FILE_EDIT_TOOL_NAME} on ${memoryPath} is allowed`, 324: }, 325: } 326: } 327: } 328: function updateLastSummarizedMessageIdIfSafe(messages: Message[]): void { 329: if (!hasToolCallsInLastAssistantTurn(messages)) { 330: const lastMessage = messages[messages.length - 1] 331: if (lastMessage?.uuid) { 332: setLastSummarizedMessageId(lastMessage.uuid) 333: } 334: } 335: }

File: src/services/SessionMemory/sessionMemoryUtils.ts

typescript 1: import { isFsInaccessible } from '../../utils/errors.js' 2: import { getFsImplementation } from '../../utils/fsOperations.js' 3: import { getSessionMemoryPath } from '../../utils/permissions/filesystem.js' 4: import { sleep } from '../../utils/sleep.js' 5: import { logEvent } from '../analytics/index.js' 6: const EXTRACTION_WAIT_TIMEOUT_MS = 15000 7: const EXTRACTION_STALE_THRESHOLD_MS = 60000 8: export type SessionMemoryConfig = { 9: minimumMessageTokensToInit: number 10: minimumTokensBetweenUpdate: number 11: toolCallsBetweenUpdates: number 12: } 13: export const DEFAULT_SESSION_MEMORY_CONFIG: SessionMemoryConfig = { 14: minimumMessageTokensToInit: 10000, 15: minimumTokensBetweenUpdate: 5000, 16: toolCallsBetweenUpdates: 3, 17: } 18: let sessionMemoryConfig: SessionMemoryConfig = { 19: ...DEFAULT_SESSION_MEMORY_CONFIG, 20: } 21: let lastSummarizedMessageId: string | undefined 22: let extractionStartedAt: number | undefined 23: let tokensAtLastExtraction = 0 24: let sessionMemoryInitialized = false 25: export function getLastSummarizedMessageId(): string | undefined { 26: return lastSummarizedMessageId 27: } 28: export function setLastSummarizedMessageId( 29: messageId: string | undefined, 30: ): void { 31: lastSummarizedMessageId = messageId 32: } 33: export function markExtractionStarted(): void { 34: extractionStartedAt = Date.now() 35: } 36: export function markExtractionCompleted(): void { 37: extractionStartedAt = undefined 38: } 39: export async function waitForSessionMemoryExtraction(): Promise<void> { 40: const startTime = Date.now() 41: while (extractionStartedAt) { 42: const extractionAge = Date.now() - extractionStartedAt 43: if (extractionAge > EXTRACTION_STALE_THRESHOLD_MS) { 44: return 45: } 46: if (Date.now() - startTime > EXTRACTION_WAIT_TIMEOUT_MS) { 47: return 48: } 49: await sleep(1000) 50: } 51: } 52: export async function getSessionMemoryContent(): Promise<string | null> { 53: const fs = getFsImplementation() 54: const memoryPath = getSessionMemoryPath() 55: try { 56: const content = await fs.readFile(memoryPath, { encoding: 'utf-8' }) 57: logEvent('tengu_session_memory_loaded', { 58: content_length: content.length, 59: }) 60: return content 61: } catch (e: unknown) { 62: if (isFsInaccessible(e)) return null 63: throw e 64: } 65: } 66: export function setSessionMemoryConfig( 67: config: Partial<SessionMemoryConfig>, 68: ): void { 69: sessionMemoryConfig = { 70: ...sessionMemoryConfig, 71: ...config, 72: } 73: } 74: export function getSessionMemoryConfig(): SessionMemoryConfig { 75: return { ...sessionMemoryConfig } 76: } 77: export function recordExtractionTokenCount(currentTokenCount: number): void { 78: tokensAtLastExtraction = currentTokenCount 79: } 80: export function isSessionMemoryInitialized(): boolean { 81: return sessionMemoryInitialized 82: } 83: export function markSessionMemoryInitialized(): void { 84: sessionMemoryInitialized = true 85: } 86: export function hasMetInitializationThreshold( 87: currentTokenCount: number, 88: ): boolean { 89: return currentTokenCount >= sessionMemoryConfig.minimumMessageTokensToInit 90: } 91: export function hasMetUpdateThreshold(currentTokenCount: number): boolean { 92: const tokensSinceLastExtraction = currentTokenCount - tokensAtLastExtraction 93: return ( 94: tokensSinceLastExtraction >= sessionMemoryConfig.minimumTokensBetweenUpdate 95: ) 96: } 97: export function getToolCallsBetweenUpdates(): number { 98: return sessionMemoryConfig.toolCallsBetweenUpdates 99: } 100: export function resetSessionMemoryState(): void { 101: sessionMemoryConfig = { ...DEFAULT_SESSION_MEMORY_CONFIG } 102: tokensAtLastExtraction = 0 103: sessionMemoryInitialized = false 104: lastSummarizedMessageId = undefined 105: extractionStartedAt = undefined 106: }

File: src/services/settingsSync/index.ts

typescript 1: import { feature } from 'bun:bundle' 2: import axios from 'axios' 3: import { mkdir, readFile, stat, writeFile } from 'fs/promises' 4: import pickBy from 'lodash-es/pickBy.js' 5: import { dirname } from 'path' 6: import { getIsInteractive } from '../../bootstrap/state.js' 7: import { 8: CLAUDE_AI_INFERENCE_SCOPE, 9: getOauthConfig, 10: OAUTH_BETA_HEADER, 11: } from '../../constants/oauth.js' 12: import { 13: checkAndRefreshOAuthTokenIfNeeded, 14: getClaudeAIOAuthTokens, 15: } from '../../utils/auth.js' 16: import { clearMemoryFileCaches } from '../../utils/claudemd.js' 17: import { getMemoryPath } from '../../utils/config.js' 18: import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' 19: import { classifyAxiosError } from '../../utils/errors.js' 20: import { getRepoRemoteHash } from '../../utils/git.js' 21: import { 22: getAPIProvider, 23: isFirstPartyAnthropicBaseUrl, 24: } from '../../utils/model/providers.js' 25: import { markInternalWrite } from '../../utils/settings/internalWrites.js' 26: import { getSettingsFilePathForSource } from '../../utils/settings/settings.js' 27: import { resetSettingsCache } from '../../utils/settings/settingsCache.js' 28: import { sleep } from '../../utils/sleep.js' 29: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 30: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 31: import { logEvent } from '../analytics/index.js' 32: import { getRetryDelay } from '../api/withRetry.js' 33: import { 34: type SettingsSyncFetchResult, 35: type SettingsSyncUploadResult, 36: SYNC_KEYS, 37: UserSyncDataSchema, 38: } from './types.js' 39: const SETTINGS_SYNC_TIMEOUT_MS = 10000 40: const DEFAULT_MAX_RETRIES = 3 41: const MAX_FILE_SIZE_BYTES = 500 * 1024 42: export async function uploadUserSettingsInBackground(): Promise<void> { 43: try { 44: if ( 45: !feature('UPLOAD_USER_SETTINGS') || 46: !getFeatureValue_CACHED_MAY_BE_STALE( 47: 'tengu_enable_settings_sync_push', 48: false, 49: ) || 50: !getIsInteractive() || 51: !isUsingOAuth() 52: ) { 53: logForDiagnosticsNoPII('info', 'settings_sync_upload_skipped') 54: logEvent('tengu_settings_sync_upload_skipped_ineligible', {}) 55: return 56: } 57: logForDiagnosticsNoPII('info', 'settings_sync_upload_starting') 58: const result = await fetchUserSettings() 59: if (!result.success) { 60: logForDiagnosticsNoPII('warn', 'settings_sync_upload_fetch_failed') 61: logEvent('tengu_settings_sync_upload_fetch_failed', {}) 62: return 63: } 64: const projectId = await getRepoRemoteHash() 65: const localEntries = await buildEntriesFromLocalFiles(projectId) 66: const remoteEntries = result.isEmpty ? {} : result.data!.content.entries 67: const changedEntries = pickBy( 68: localEntries, 69: (value, key) => remoteEntries[key] !== value, 70: ) 71: const entryCount = Object.keys(changedEntries).length 72: if (entryCount === 0) { 73: logForDiagnosticsNoPII('info', 'settings_sync_upload_no_changes') 74: logEvent('tengu_settings_sync_upload_skipped', {}) 75: return 76: } 77: const uploadResult = await uploadUserSettings(changedEntries) 78: if (uploadResult.success) { 79: logForDiagnosticsNoPII('info', 'settings_sync_upload_success') 80: logEvent('tengu_settings_sync_upload_success', { entryCount }) 81: } else { 82: logForDiagnosticsNoPII('warn', 'settings_sync_upload_failed') 83: logEvent('tengu_settings_sync_upload_failed', { entryCount }) 84: } 85: } catch { 86: logForDiagnosticsNoPII('error', 'settings_sync_unexpected_error') 87: } 88: } 89: let downloadPromise: Promise<boolean> | null = null 90: export function _resetDownloadPromiseForTesting(): void { 91: downloadPromise = null 92: } 93: export function downloadUserSettings(): Promise<boolean> { 94: if (downloadPromise) { 95: return downloadPromise 96: } 97: downloadPromise = doDownloadUserSettings() 98: return downloadPromise 99: } 100: export function redownloadUserSettings(): Promise<boolean> { 101: downloadPromise = doDownloadUserSettings(0) 102: return downloadPromise 103: } 104: async function doDownloadUserSettings( 105: maxRetries = DEFAULT_MAX_RETRIES, 106: ): Promise<boolean> { 107: if (feature('DOWNLOAD_USER_SETTINGS')) { 108: try { 109: if ( 110: !getFeatureValue_CACHED_MAY_BE_STALE('tengu_strap_foyer', false) || 111: !isUsingOAuth() 112: ) { 113: logForDiagnosticsNoPII('info', 'settings_sync_download_skipped') 114: logEvent('tengu_settings_sync_download_skipped', {}) 115: return false 116: } 117: logForDiagnosticsNoPII('info', 'settings_sync_download_starting') 118: const result = await fetchUserSettings(maxRetries) 119: if (!result.success) { 120: logForDiagnosticsNoPII('warn', 'settings_sync_download_fetch_failed') 121: logEvent('tengu_settings_sync_download_fetch_failed', {}) 122: return false 123: } 124: if (result.isEmpty) { 125: logForDiagnosticsNoPII('info', 'settings_sync_download_empty') 126: logEvent('tengu_settings_sync_download_empty', {}) 127: return false 128: } 129: const entries = result.data!.content.entries 130: const projectId = await getRepoRemoteHash() 131: const entryCount = Object.keys(entries).length 132: logForDiagnosticsNoPII('info', 'settings_sync_download_applying', { 133: entryCount, 134: }) 135: await applyRemoteEntriesToLocal(entries, projectId) 136: logEvent('tengu_settings_sync_download_success', { entryCount }) 137: return true 138: } catch { 139: logForDiagnosticsNoPII('error', 'settings_sync_download_error') 140: logEvent('tengu_settings_sync_download_error', {}) 141: return false 142: } 143: } 144: return false 145: } 146: function isUsingOAuth(): boolean { 147: if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) { 148: return false 149: } 150: const tokens = getClaudeAIOAuthTokens() 151: return Boolean( 152: tokens?.accessToken && tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE), 153: ) 154: } 155: function getSettingsSyncEndpoint(): string { 156: return `${getOauthConfig().BASE_API_URL}/api/claude_code/user_settings` 157: } 158: function getSettingsSyncAuthHeaders(): { 159: headers: Record<string, string> 160: error?: string 161: } { 162: const oauthTokens = getClaudeAIOAuthTokens() 163: if (oauthTokens?.accessToken) { 164: return { 165: headers: { 166: Authorization: `Bearer ${oauthTokens.accessToken}`, 167: 'anthropic-beta': OAUTH_BETA_HEADER, 168: }, 169: } 170: } 171: return { 172: headers: {}, 173: error: 'No OAuth token available', 174: } 175: } 176: async function fetchUserSettingsOnce(): Promise<SettingsSyncFetchResult> { 177: try { 178: await checkAndRefreshOAuthTokenIfNeeded() 179: const authHeaders = getSettingsSyncAuthHeaders() 180: if (authHeaders.error) { 181: return { 182: success: false, 183: error: authHeaders.error, 184: skipRetry: true, 185: } 186: } 187: const headers: Record<string, string> = { 188: ...authHeaders.headers, 189: 'User-Agent': getClaudeCodeUserAgent(), 190: } 191: const endpoint = getSettingsSyncEndpoint() 192: const response = await axios.get(endpoint, { 193: headers, 194: timeout: SETTINGS_SYNC_TIMEOUT_MS, 195: validateStatus: status => status === 200 || status === 404, 196: }) 197: if (response.status === 404) { 198: logForDiagnosticsNoPII('info', 'settings_sync_fetch_empty') 199: return { 200: success: true, 201: isEmpty: true, 202: } 203: } 204: const parsed = UserSyncDataSchema().safeParse(response.data) 205: if (!parsed.success) { 206: logForDiagnosticsNoPII('warn', 'settings_sync_fetch_invalid_format') 207: return { 208: success: false, 209: error: 'Invalid settings sync response format', 210: } 211: } 212: logForDiagnosticsNoPII('info', 'settings_sync_fetch_success') 213: return { 214: success: true, 215: data: parsed.data, 216: isEmpty: false, 217: } 218: } catch (error) { 219: const { kind, message } = classifyAxiosError(error) 220: switch (kind) { 221: case 'auth': 222: return { 223: success: false, 224: error: 'Not authorized for settings sync', 225: skipRetry: true, 226: } 227: case 'timeout': 228: return { success: false, error: 'Settings sync request timeout' } 229: case 'network': 230: return { success: false, error: 'Cannot connect to server' } 231: default: 232: return { success: false, error: message } 233: } 234: } 235: } 236: async function fetchUserSettings( 237: maxRetries = DEFAULT_MAX_RETRIES, 238: ): Promise<SettingsSyncFetchResult> { 239: let lastResult: SettingsSyncFetchResult | null = null 240: for (let attempt = 1; attempt <= maxRetries + 1; attempt++) { 241: lastResult = await fetchUserSettingsOnce() 242: if (lastResult.success) { 243: return lastResult 244: } 245: if (lastResult.skipRetry) { 246: return lastResult 247: } 248: if (attempt > maxRetries) { 249: return lastResult 250: } 251: const delayMs = getRetryDelay(attempt) 252: logForDiagnosticsNoPII('info', 'settings_sync_retry', { 253: attempt, 254: maxRetries, 255: delayMs, 256: }) 257: await sleep(delayMs) 258: } 259: return lastResult! 260: } 261: async function uploadUserSettings( 262: entries: Record<string, string>, 263: ): Promise<SettingsSyncUploadResult> { 264: try { 265: await checkAndRefreshOAuthTokenIfNeeded() 266: const authHeaders = getSettingsSyncAuthHeaders() 267: if (authHeaders.error) { 268: return { 269: success: false, 270: error: authHeaders.error, 271: } 272: } 273: const headers: Record<string, string> = { 274: ...authHeaders.headers, 275: 'User-Agent': getClaudeCodeUserAgent(), 276: 'Content-Type': 'application/json', 277: } 278: const endpoint = getSettingsSyncEndpoint() 279: const response = await axios.put( 280: endpoint, 281: { entries }, 282: { 283: headers, 284: timeout: SETTINGS_SYNC_TIMEOUT_MS, 285: }, 286: ) 287: logForDiagnosticsNoPII('info', 'settings_sync_uploaded', { 288: entryCount: Object.keys(entries).length, 289: }) 290: return { 291: success: true, 292: checksum: response.data?.checksum, 293: lastModified: response.data?.lastModified, 294: } 295: } catch (error) { 296: logForDiagnosticsNoPII('warn', 'settings_sync_upload_error') 297: return { 298: success: false, 299: error: error instanceof Error ? error.message : 'Unknown error', 300: } 301: } 302: } 303: async function tryReadFileForSync(filePath: string): Promise<string | null> { 304: try { 305: const stats = await stat(filePath) 306: if (stats.size > MAX_FILE_SIZE_BYTES) { 307: logForDiagnosticsNoPII('info', 'settings_sync_file_too_large') 308: return null 309: } 310: const content = await readFile(filePath, 'utf8') 311: if (!content || /^\s*$/.test(content)) { 312: return null 313: } 314: return content 315: } catch { 316: return null 317: } 318: } 319: async function buildEntriesFromLocalFiles( 320: projectId: string | null, 321: ): Promise<Record<string, string>> { 322: const entries: Record<string, string> = {} 323: const userSettingsPath = getSettingsFilePathForSource('userSettings') 324: if (userSettingsPath) { 325: const content = await tryReadFileForSync(userSettingsPath) 326: if (content) { 327: entries[SYNC_KEYS.USER_SETTINGS] = content 328: } 329: } 330: const userMemoryPath = getMemoryPath('User') 331: const userMemoryContent = await tryReadFileForSync(userMemoryPath) 332: if (userMemoryContent) { 333: entries[SYNC_KEYS.USER_MEMORY] = userMemoryContent 334: } 335: if (projectId) { 336: const localSettingsPath = getSettingsFilePathForSource('localSettings') 337: if (localSettingsPath) { 338: const content = await tryReadFileForSync(localSettingsPath) 339: if (content) { 340: entries[SYNC_KEYS.projectSettings(projectId)] = content 341: } 342: } 343: const localMemoryPath = getMemoryPath('Local') 344: const localMemoryContent = await tryReadFileForSync(localMemoryPath) 345: if (localMemoryContent) { 346: entries[SYNC_KEYS.projectMemory(projectId)] = localMemoryContent 347: } 348: } 349: return entries 350: } 351: async function writeFileForSync( 352: filePath: string, 353: content: string, 354: ): Promise<boolean> { 355: try { 356: const parentDir = dirname(filePath) 357: if (parentDir) { 358: await mkdir(parentDir, { recursive: true }) 359: } 360: await writeFile(filePath, content, 'utf8') 361: logForDiagnosticsNoPII('info', 'settings_sync_file_written') 362: return true 363: } catch { 364: logForDiagnosticsNoPII('warn', 'settings_sync_file_write_failed') 365: return false 366: } 367: } 368: async function applyRemoteEntriesToLocal( 369: entries: Record<string, string>, 370: projectId: string | null, 371: ): Promise<void> { 372: let appliedCount = 0 373: let settingsWritten = false 374: let memoryWritten = false 375: const exceedsSizeLimit = (content: string, _path: string): boolean => { 376: const sizeBytes = Buffer.byteLength(content, 'utf8') 377: if (sizeBytes > MAX_FILE_SIZE_BYTES) { 378: logForDiagnosticsNoPII('info', 'settings_sync_file_too_large', { 379: sizeBytes, 380: maxBytes: MAX_FILE_SIZE_BYTES, 381: }) 382: return true 383: } 384: return false 385: } 386: const userSettingsContent = entries[SYNC_KEYS.USER_SETTINGS] 387: if (userSettingsContent) { 388: const userSettingsPath = getSettingsFilePathForSource('userSettings') 389: if ( 390: userSettingsPath && 391: !exceedsSizeLimit(userSettingsContent, userSettingsPath) 392: ) { 393: markInternalWrite(userSettingsPath) 394: if (await writeFileForSync(userSettingsPath, userSettingsContent)) { 395: appliedCount++ 396: settingsWritten = true 397: } 398: } 399: } 400: const userMemoryContent = entries[SYNC_KEYS.USER_MEMORY] 401: if (userMemoryContent) { 402: const userMemoryPath = getMemoryPath('User') 403: if (!exceedsSizeLimit(userMemoryContent, userMemoryPath)) { 404: if (await writeFileForSync(userMemoryPath, userMemoryContent)) { 405: appliedCount++ 406: memoryWritten = true 407: } 408: } 409: } 410: if (projectId) { 411: const projectSettingsKey = SYNC_KEYS.projectSettings(projectId) 412: const projectSettingsContent = entries[projectSettingsKey] 413: if (projectSettingsContent) { 414: const localSettingsPath = getSettingsFilePathForSource('localSettings') 415: if ( 416: localSettingsPath && 417: !exceedsSizeLimit(projectSettingsContent, localSettingsPath) 418: ) { 419: markInternalWrite(localSettingsPath) 420: if (await writeFileForSync(localSettingsPath, projectSettingsContent)) { 421: appliedCount++ 422: settingsWritten = true 423: } 424: } 425: } 426: const projectMemoryKey = SYNC_KEYS.projectMemory(projectId) 427: const projectMemoryContent = entries[projectMemoryKey] 428: if (projectMemoryContent) { 429: const localMemoryPath = getMemoryPath('Local') 430: if (!exceedsSizeLimit(projectMemoryContent, localMemoryPath)) { 431: if (await writeFileForSync(localMemoryPath, projectMemoryContent)) { 432: appliedCount++ 433: memoryWritten = true 434: } 435: } 436: } 437: } 438: if (settingsWritten) { 439: resetSettingsCache() 440: } 441: if (memoryWritten) { 442: clearMemoryFileCaches() 443: } 444: logForDiagnosticsNoPII('info', 'settings_sync_applied', { 445: appliedCount, 446: }) 447: }

File: src/services/settingsSync/types.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: export const UserSyncContentSchema = lazySchema(() => 4: z.object({ 5: entries: z.record(z.string(), z.string()), 6: }), 7: ) 8: export const UserSyncDataSchema = lazySchema(() => 9: z.object({ 10: userId: z.string(), 11: version: z.number(), 12: lastModified: z.string(), 13: checksum: z.string(), 14: content: UserSyncContentSchema(), 15: }), 16: ) 17: export type UserSyncData = z.infer<ReturnType<typeof UserSyncDataSchema>> 18: export type SettingsSyncFetchResult = { 19: success: boolean 20: data?: UserSyncData 21: isEmpty?: boolean 22: error?: string 23: skipRetry?: boolean 24: } 25: export type SettingsSyncUploadResult = { 26: success: boolean 27: checksum?: string 28: lastModified?: string 29: error?: string 30: } 31: export const SYNC_KEYS = { 32: USER_SETTINGS: '~/.claude/settings.json', 33: USER_MEMORY: '~/.claude/CLAUDE.md', 34: projectSettings: (projectId: string) => 35: `projects/${projectId}/.claude/settings.local.json`, 36: projectMemory: (projectId: string) => `projects/${projectId}/CLAUDE.local.md`, 37: } as const

File: src/services/teamMemorySync/index.ts

typescript 1: import axios from 'axios' 2: import { createHash } from 'crypto' 3: import { mkdir, readdir, readFile, stat, writeFile } from 'fs/promises' 4: import { join, relative, sep } from 'path' 5: import { 6: CLAUDE_AI_INFERENCE_SCOPE, 7: CLAUDE_AI_PROFILE_SCOPE, 8: getOauthConfig, 9: OAUTH_BETA_HEADER, 10: } from '../../constants/oauth.js' 11: import { 12: getTeamMemPath, 13: PathTraversalError, 14: validateTeamMemKey, 15: } from '../../memdir/teamMemPaths.js' 16: import { count } from '../../utils/array.js' 17: import { 18: checkAndRefreshOAuthTokenIfNeeded, 19: getClaudeAIOAuthTokens, 20: } from '../../utils/auth.js' 21: import { logForDebugging } from '../../utils/debug.js' 22: import { classifyAxiosError } from '../../utils/errors.js' 23: import { getGithubRepo } from '../../utils/git.js' 24: import { 25: getAPIProvider, 26: isFirstPartyAnthropicBaseUrl, 27: } from '../../utils/model/providers.js' 28: import { sleep } from '../../utils/sleep.js' 29: import { jsonStringify } from '../../utils/slowOperations.js' 30: import { getClaudeCodeUserAgent } from '../../utils/userAgent.js' 31: import { logEvent } from '../analytics/index.js' 32: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../analytics/metadata.js' 33: import { getRetryDelay } from '../api/withRetry.js' 34: import { scanForSecrets } from './secretScanner.js' 35: import { 36: type SkippedSecretFile, 37: TeamMemoryDataSchema, 38: type TeamMemoryHashesResult, 39: type TeamMemorySyncFetchResult, 40: type TeamMemorySyncPushResult, 41: type TeamMemorySyncUploadResult, 42: TeamMemoryTooManyEntriesSchema, 43: } from './types.js' 44: const TEAM_MEMORY_SYNC_TIMEOUT_MS = 30_000 45: const MAX_FILE_SIZE_BYTES = 250_000 46: const MAX_PUT_BODY_BYTES = 200_000 47: const MAX_RETRIES = 3 48: const MAX_CONFLICT_RETRIES = 2 49: export type SyncState = { 50: lastKnownChecksum: string | null 51: serverChecksums: Map<string, string> 52: serverMaxEntries: number | null 53: } 54: export function createSyncState(): SyncState { 55: return { 56: lastKnownChecksum: null, 57: serverChecksums: new Map(), 58: serverMaxEntries: null, 59: } 60: } 61: export function hashContent(content: string): string { 62: return 'sha256:' + createHash('sha256').update(content, 'utf8').digest('hex') 63: } 64: function isErrnoException(e: unknown): e is NodeJS.ErrnoException { 65: return e instanceof Error && 'code' in e && typeof e.code === 'string' 66: } 67: function isUsingOAuth(): boolean { 68: if (getAPIProvider() !== 'firstParty' || !isFirstPartyAnthropicBaseUrl()) { 69: return false 70: } 71: const tokens = getClaudeAIOAuthTokens() 72: return Boolean( 73: tokens?.accessToken && 74: tokens.scopes?.includes(CLAUDE_AI_INFERENCE_SCOPE) && 75: tokens.scopes.includes(CLAUDE_AI_PROFILE_SCOPE), 76: ) 77: } 78: function getTeamMemorySyncEndpoint(repoSlug: string): string { 79: const baseUrl = 80: process.env.TEAM_MEMORY_SYNC_URL || getOauthConfig().BASE_API_URL 81: return `${baseUrl}/api/claude_code/team_memory?repo=${encodeURIComponent(repoSlug)}` 82: } 83: function getAuthHeaders(): { 84: headers?: Record<string, string> 85: error?: string 86: } { 87: const oauthTokens = getClaudeAIOAuthTokens() 88: if (oauthTokens?.accessToken) { 89: return { 90: headers: { 91: Authorization: `Bearer ${oauthTokens.accessToken}`, 92: 'anthropic-beta': OAUTH_BETA_HEADER, 93: 'User-Agent': getClaudeCodeUserAgent(), 94: }, 95: } 96: } 97: return { error: 'No OAuth token available for team memory sync' } 98: } 99: async function fetchTeamMemoryOnce( 100: state: SyncState, 101: repoSlug: string, 102: etag?: string | null, 103: ): Promise<TeamMemorySyncFetchResult> { 104: try { 105: await checkAndRefreshOAuthTokenIfNeeded() 106: const auth = getAuthHeaders() 107: if (auth.error) { 108: return { 109: success: false, 110: error: auth.error, 111: skipRetry: true, 112: errorType: 'auth', 113: } 114: } 115: const headers: Record<string, string> = { ...auth.headers } 116: if (etag) { 117: headers['If-None-Match'] = `"${etag.replace(/"/g, '')}"` 118: } 119: const endpoint = getTeamMemorySyncEndpoint(repoSlug) 120: const response = await axios.get(endpoint, { 121: headers, 122: timeout: TEAM_MEMORY_SYNC_TIMEOUT_MS, 123: validateStatus: status => 124: status === 200 || status === 304 || status === 404, 125: }) 126: if (response.status === 304) { 127: logForDebugging('team-memory-sync: not modified (304)', { 128: level: 'debug', 129: }) 130: return { success: true, notModified: true, checksum: etag ?? undefined } 131: } 132: if (response.status === 404) { 133: logForDebugging('team-memory-sync: no remote data (404)', { 134: level: 'debug', 135: }) 136: state.lastKnownChecksum = null 137: return { success: true, isEmpty: true } 138: } 139: const parsed = TeamMemoryDataSchema().safeParse(response.data) 140: if (!parsed.success) { 141: logForDebugging('team-memory-sync: invalid response format', { 142: level: 'warn', 143: }) 144: return { 145: success: false, 146: error: 'Invalid team memory response format', 147: skipRetry: true, 148: errorType: 'parse', 149: } 150: } 151: const responseChecksum = 152: parsed.data.checksum || 153: response.headers['etag']?.replace(/^"|"$/g, '') || 154: undefined 155: if (responseChecksum) { 156: state.lastKnownChecksum = responseChecksum 157: } 158: logForDebugging( 159: `team-memory-sync: fetched successfully (checksum: ${responseChecksum ?? 'none'})`, 160: { level: 'debug' }, 161: ) 162: return { 163: success: true, 164: data: parsed.data, 165: isEmpty: false, 166: checksum: responseChecksum, 167: } 168: } catch (error) { 169: const { kind, status, message } = classifyAxiosError(error) 170: const body = axios.isAxiosError(error) 171: ? JSON.stringify(error.response?.data ?? '') 172: : '' 173: if (kind !== 'other') { 174: logForDebugging(`team-memory-sync: fetch error ${status}: ${body}`, { 175: level: 'warn', 176: }) 177: } 178: switch (kind) { 179: case 'auth': 180: return { 181: success: false, 182: error: `Not authorized for team memory sync: ${body}`, 183: skipRetry: true, 184: errorType: 'auth', 185: httpStatus: status, 186: } 187: case 'timeout': 188: return { 189: success: false, 190: error: 'Team memory sync request timeout', 191: errorType: 'timeout', 192: } 193: case 'network': 194: return { 195: success: false, 196: error: 'Cannot connect to server', 197: errorType: 'network', 198: } 199: default: 200: return { 201: success: false, 202: error: message, 203: errorType: 'unknown', 204: httpStatus: status, 205: } 206: } 207: } 208: } 209: /** 210: * Fetch only per-key checksums + metadata (no entry bodies). 211: * Used for cheap serverChecksums refresh during 412 conflict resolution — avoids 212: * downloading ~300KB of content just to learn which keys changed. 213: * Requires anthropic/anthropic#283027 deployed; on failure the caller fails the 214: * push and the watcher retries on the next edit. 215: */ 216: async function fetchTeamMemoryHashes( 217: state: SyncState, 218: repoSlug: string, 219: ): Promise<TeamMemoryHashesResult> { 220: try { 221: await checkAndRefreshOAuthTokenIfNeeded() 222: const auth = getAuthHeaders() 223: if (auth.error) { 224: return { success: false, error: auth.error, errorType: 'auth' } 225: } 226: const endpoint = getTeamMemorySyncEndpoint(repoSlug) + '&view=hashes' 227: const response = await axios.get(endpoint, { 228: headers: auth.headers, 229: timeout: TEAM_MEMORY_SYNC_TIMEOUT_MS, 230: validateStatus: status => status === 200 || status === 404, 231: }) 232: if (response.status === 404) { 233: state.lastKnownChecksum = null 234: return { success: true, entryChecksums: {} } 235: } 236: const checksum = 237: response.data?.checksum || response.headers['etag']?.replace(/^"|"$/g, '') 238: const entryChecksums = response.data?.entryChecksums 239: // Requires anthropic/anthropic#283027. If entryChecksums is missing, 240: // treat as a probe failure — caller fails the push; watcher retries. 241: if (!entryChecksums || typeof entryChecksums !== 'object') { 242: return { 243: success: false, 244: error: 245: 'Server did not return entryChecksums (?view=hashes unsupported)', 246: errorType: 'parse', 247: } 248: } 249: if (checksum) { 250: state.lastKnownChecksum = checksum 251: } 252: return { 253: success: true, 254: version: response.data?.version, 255: checksum, 256: entryChecksums, 257: } 258: } catch (error) { 259: const { kind, status, message } = classifyAxiosError(error) 260: switch (kind) { 261: case 'auth': 262: return { 263: success: false, 264: error: 'Not authorized', 265: errorType: 'auth', 266: httpStatus: status, 267: } 268: case 'timeout': 269: return { success: false, error: 'Timeout', errorType: 'timeout' } 270: case 'network': 271: return { success: false, error: 'Network error', errorType: 'network' } 272: default: 273: return { 274: success: false, 275: error: message, 276: errorType: 'unknown', 277: httpStatus: status, 278: } 279: } 280: } 281: } 282: async function fetchTeamMemory( 283: state: SyncState, 284: repoSlug: string, 285: etag?: string | null, 286: ): Promise<TeamMemorySyncFetchResult> { 287: let lastResult: TeamMemorySyncFetchResult | null = null 288: for (let attempt = 1; attempt <= MAX_RETRIES + 1; attempt++) { 289: lastResult = await fetchTeamMemoryOnce(state, repoSlug, etag) 290: if (lastResult.success || lastResult.skipRetry) { 291: return lastResult 292: } 293: if (attempt > MAX_RETRIES) { 294: return lastResult 295: } 296: const delayMs = getRetryDelay(attempt) 297: logForDebugging(`team-memory-sync: retry ${attempt}/${MAX_RETRIES}`, { 298: level: 'debug', 299: }) 300: await sleep(delayMs) 301: } 302: return lastResult! 303: } 304: // ─── Upload (push) ─────────────────────────────────────────── 305: /** 306: * Split a delta into PUT-sized batches under MAX_PUT_BODY_BYTES each. 307: * 308: * Greedy bin-packing over sorted keys — sorting gives deterministic batches 309: * across calls, which matters for ETag stability if the conflict loop retries 310: * after a partial commit. The byte count is the full serialized body 311: * including JSON overhead, so what we measure is what axios sends. 312: * 313: * A single entry exceeding MAX_PUT_BODY_BYTES goes into its own solo batch 314: * (MAX_FILE_SIZE_BYTES=250K already caps individual files; a ~250K solo body 315: * is above our soft cap but below the gateway's observed real threshold). 316: */ 317: export function batchDeltaByBytes( 318: delta: Record<string, string>, 319: ): Array<Record<string, string>> { 320: const keys = Object.keys(delta).sort() 321: if (keys.length === 0) return [] 322: // Fixed overhead for `{"entries":{}}` — each entry then adds its marginal 323: // bytes. jsonStringify (≡ JSON.stringify under the hood) on the raw 324: // strings handles escaping so the count matches what axios serializes. 325: const EMPTY_BODY_BYTES = Buffer.byteLength('{"entries":{}}', 'utf8') 326: const entryBytes = (k: string, v: string): number => 327: Buffer.byteLength(jsonStringify(k), 'utf8') + 328: Buffer.byteLength(jsonStringify(v), 'utf8') + 329: 2 // colon + comma (comma over-counts by 1 on the last entry; harmless slack) 330: const batches: Array<Record<string, string>> = [] 331: let current: Record<string, string> = {} 332: let currentBytes = EMPTY_BODY_BYTES 333: for (const key of keys) { 334: const added = entryBytes(key, delta[key]!) 335: if ( 336: currentBytes + added > MAX_PUT_BODY_BYTES && 337: Object.keys(current).length > 0 338: ) { 339: batches.push(current) 340: current = {} 341: currentBytes = EMPTY_BODY_BYTES 342: } 343: current[key] = delta[key]! 344: currentBytes += added 345: } 346: batches.push(current) 347: return batches 348: } 349: async function uploadTeamMemory( 350: state: SyncState, 351: repoSlug: string, 352: entries: Record<string, string>, 353: ifMatchChecksum?: string | null, 354: ): Promise<TeamMemorySyncUploadResult> { 355: try { 356: await checkAndRefreshOAuthTokenIfNeeded() 357: const auth = getAuthHeaders() 358: if (auth.error) { 359: return { success: false, error: auth.error, errorType: 'auth' } 360: } 361: const headers: Record<string, string> = { 362: ...auth.headers, 363: 'Content-Type': 'application/json', 364: } 365: if (ifMatchChecksum) { 366: headers['If-Match'] = `"${ifMatchChecksum.replace(/"/g, '')}"` 367: } 368: const endpoint = getTeamMemorySyncEndpoint(repoSlug) 369: const response = await axios.put( 370: endpoint, 371: { entries }, 372: { 373: headers, 374: timeout: TEAM_MEMORY_SYNC_TIMEOUT_MS, 375: validateStatus: status => status === 200 || status === 412, 376: }, 377: ) 378: if (response.status === 412) { 379: logForDebugging('team-memory-sync: conflict (412 Precondition Failed)', { 380: level: 'info', 381: }) 382: return { success: false, conflict: true, error: 'ETag mismatch' } 383: } 384: const responseChecksum = response.data?.checksum 385: if (responseChecksum) { 386: state.lastKnownChecksum = responseChecksum 387: } 388: logForDebugging( 389: `team-memory-sync: uploaded ${Object.keys(entries).length} entries (checksum: ${responseChecksum ?? 'none'})`, 390: { level: 'debug' }, 391: ) 392: return { 393: success: true, 394: checksum: responseChecksum, 395: lastModified: response.data?.lastModified, 396: } 397: } catch (error) { 398: const body = axios.isAxiosError(error) 399: ? JSON.stringify(error.response?.data ?? '') 400: : '' 401: logForDebugging( 402: `team-memory-sync: upload failed: ${error instanceof Error ? error.message : ''} ${body}`, 403: { level: 'warn' }, 404: ) 405: const { kind, status: httpStatus, message } = classifyAxiosError(error) 406: const errorType = kind === 'http' || kind === 'other' ? 'unknown' : kind 407: let serverErrorCode: 'team_memory_too_many_entries' | undefined 408: let serverMaxEntries: number | undefined 409: let serverReceivedEntries: number | undefined 410: if (httpStatus === 413 && axios.isAxiosError(error)) { 411: const parsed = TeamMemoryTooManyEntriesSchema().safeParse( 412: error.response?.data, 413: ) 414: if (parsed.success) { 415: serverErrorCode = parsed.data.error.details.error_code 416: serverMaxEntries = parsed.data.error.details.max_entries 417: serverReceivedEntries = parsed.data.error.details.received_entries 418: } 419: } 420: return { 421: success: false, 422: error: message, 423: errorType, 424: httpStatus, 425: ...(serverErrorCode !== undefined && { serverErrorCode }), 426: ...(serverMaxEntries !== undefined && { serverMaxEntries }), 427: ...(serverReceivedEntries !== undefined && { serverReceivedEntries }), 428: } 429: } 430: } 431: async function readLocalTeamMemory(maxEntries: number | null): Promise<{ 432: entries: Record<string, string> 433: skippedSecrets: SkippedSecretFile[] 434: }> { 435: const teamDir = getTeamMemPath() 436: const entries: Record<string, string> = {} 437: const skippedSecrets: SkippedSecretFile[] = [] 438: async function walkDir(dir: string): Promise<void> { 439: try { 440: const dirEntries = await readdir(dir, { withFileTypes: true }) 441: await Promise.all( 442: dirEntries.map(async entry => { 443: const fullPath = join(dir, entry.name) 444: if (entry.isDirectory()) { 445: await walkDir(fullPath) 446: } else if (entry.isFile()) { 447: try { 448: const stats = await stat(fullPath) 449: if (stats.size > MAX_FILE_SIZE_BYTES) { 450: logForDebugging( 451: `team-memory-sync: skipping oversized file ${entry.name} (${stats.size} > ${MAX_FILE_SIZE_BYTES} bytes)`, 452: { level: 'info' }, 453: ) 454: return 455: } 456: const content = await readFile(fullPath, 'utf8') 457: const relPath = relative(teamDir, fullPath).replaceAll('\\', '/') 458: // PSR M22174: scan for secrets BEFORE adding to the upload 459: // payload. If a secret is detected, skip this file entirely 460: // so it never leaves the machine. 461: const secretMatches = scanForSecrets(content) 462: if (secretMatches.length > 0) { 463: // Report only the first match per file — one secret is 464: // enough to skip the file and we don't want to log more 465: const firstMatch = secretMatches[0]! 466: skippedSecrets.push({ 467: path: relPath, 468: ruleId: firstMatch.ruleId, 469: label: firstMatch.label, 470: }) 471: logForDebugging( 472: `team-memory-sync: skipping "${relPath}" — detected ${firstMatch.label}`, 473: { level: 'warn' }, 474: ) 475: return 476: } 477: entries[relPath] = content 478: } catch { 479: } 480: } 481: }), 482: ) 483: } catch (e) { 484: if (isErrnoException(e)) { 485: if (e.code !== 'ENOENT' && e.code !== 'EACCES' && e.code !== 'EPERM') { 486: throw e 487: } 488: } else { 489: throw e 490: } 491: } 492: } 493: await walkDir(teamDir) 494: const keys = Object.keys(entries).sort() 495: if (maxEntries !== null && keys.length > maxEntries) { 496: const dropped = keys.slice(maxEntries) 497: logForDebugging( 498: `team-memory-sync: ${keys.length} local entries exceeds server cap of ${maxEntries}; ${dropped.length} file(s) will NOT sync: ${dropped.join(', ')}. Consider consolidating or removing some team memory files.`, 499: { level: 'warn' }, 500: ) 501: logEvent('tengu_team_mem_entries_capped', { 502: total_entries: keys.length, 503: dropped_count: dropped.length, 504: max_entries: maxEntries, 505: }) 506: const truncated: Record<string, string> = {} 507: for (const key of keys.slice(0, maxEntries)) { 508: truncated[key] = entries[key]! 509: } 510: return { entries: truncated, skippedSecrets } 511: } 512: return { entries, skippedSecrets } 513: } 514: async function writeRemoteEntriesToLocal( 515: entries: Record<string, string>, 516: ): Promise<number> { 517: const results = await Promise.all( 518: Object.entries(entries).map(async ([relPath, content]) => { 519: let validatedPath: string 520: try { 521: validatedPath = await validateTeamMemKey(relPath) 522: } catch (e) { 523: if (e instanceof PathTraversalError) { 524: logForDebugging(`team-memory-sync: ${e.message}`, { level: 'warn' }) 525: return false 526: } 527: throw e 528: } 529: const sizeBytes = Buffer.byteLength(content, 'utf8') 530: if (sizeBytes > MAX_FILE_SIZE_BYTES) { 531: logForDebugging( 532: `team-memory-sync: skipping oversized remote entry "${relPath}"`, 533: { level: 'info' }, 534: ) 535: return false 536: } 537: try { 538: const existing = await readFile(validatedPath, 'utf8') 539: if (existing === content) { 540: return false 541: } 542: } catch (e) { 543: if ( 544: isErrnoException(e) && 545: e.code !== 'ENOENT' && 546: e.code !== 'ENOTDIR' 547: ) { 548: logForDebugging( 549: `team-memory-sync: unexpected read error for "${relPath}": ${e.code}`, 550: { level: 'debug' }, 551: ) 552: } 553: } 554: try { 555: const parentDir = validatedPath.substring( 556: 0, 557: validatedPath.lastIndexOf(sep), 558: ) 559: await mkdir(parentDir, { recursive: true }) 560: await writeFile(validatedPath, content, 'utf8') 561: return true 562: } catch (e) { 563: logForDebugging( 564: `team-memory-sync: failed to write "${relPath}": ${e}`, 565: { level: 'warn' }, 566: ) 567: return false 568: } 569: }), 570: ) 571: return count(results, Boolean) 572: } 573: export function isTeamMemorySyncAvailable(): boolean { 574: return isUsingOAuth() 575: } 576: export async function pullTeamMemory( 577: state: SyncState, 578: options?: { skipEtagCache?: boolean }, 579: ): Promise<{ 580: success: boolean 581: filesWritten: number 582: entryCount: number 583: notModified?: boolean 584: error?: string 585: }> { 586: const skipEtagCache = options?.skipEtagCache ?? false 587: const startTime = Date.now() 588: if (!isUsingOAuth()) { 589: logPull(startTime, { success: false, errorType: 'no_oauth' }) 590: return { 591: success: false, 592: filesWritten: 0, 593: entryCount: 0, 594: error: 'OAuth not available', 595: } 596: } 597: const repoSlug = await getGithubRepo() 598: if (!repoSlug) { 599: logPull(startTime, { success: false, errorType: 'no_repo' }) 600: return { 601: success: false, 602: filesWritten: 0, 603: entryCount: 0, 604: error: 'No git remote found', 605: } 606: } 607: const etag = skipEtagCache ? null : state.lastKnownChecksum 608: const result = await fetchTeamMemory(state, repoSlug, etag) 609: if (!result.success) { 610: logPull(startTime, { 611: success: false, 612: errorType: result.errorType, 613: status: result.httpStatus, 614: }) 615: return { 616: success: false, 617: filesWritten: 0, 618: entryCount: 0, 619: error: result.error, 620: } 621: } 622: if (result.notModified) { 623: logPull(startTime, { success: true, notModified: true }) 624: return { success: true, filesWritten: 0, entryCount: 0, notModified: true } 625: } 626: if (result.isEmpty || !result.data) { 627: state.serverChecksums.clear() 628: logPull(startTime, { success: true }) 629: return { success: true, filesWritten: 0, entryCount: 0 } 630: } 631: const entries = result.data.content.entries 632: const responseChecksums = result.data.content.entryChecksums 633: state.serverChecksums.clear() 634: if (responseChecksums) { 635: for (const [key, hash] of Object.entries(responseChecksums)) { 636: state.serverChecksums.set(key, hash) 637: } 638: } else { 639: logForDebugging( 640: 'team-memory-sync: server response missing entryChecksums (pre-#283027 deploy) — next push will be full, not delta', 641: { level: 'debug' }, 642: ) 643: } 644: const filesWritten = await writeRemoteEntriesToLocal(entries) 645: if (filesWritten > 0) { 646: const { clearMemoryFileCaches } = await import('../../utils/claudemd.js') 647: clearMemoryFileCaches() 648: } 649: logForDebugging(`team-memory-sync: pulled ${filesWritten} files`, { 650: level: 'info', 651: }) 652: logPull(startTime, { success: true, filesWritten }) 653: return { 654: success: true, 655: filesWritten, 656: entryCount: Object.keys(entries).length, 657: } 658: } 659: export async function pushTeamMemory( 660: state: SyncState, 661: ): Promise<TeamMemorySyncPushResult> { 662: const startTime = Date.now() 663: let conflictRetries = 0 664: if (!isUsingOAuth()) { 665: logPush(startTime, { success: false, errorType: 'no_oauth' }) 666: return { 667: success: false, 668: filesUploaded: 0, 669: error: 'OAuth not available', 670: errorType: 'no_oauth', 671: } 672: } 673: const repoSlug = await getGithubRepo() 674: if (!repoSlug) { 675: logPush(startTime, { success: false, errorType: 'no_repo' }) 676: return { 677: success: false, 678: filesUploaded: 0, 679: error: 'No git remote found', 680: errorType: 'no_repo', 681: } 682: } 683: const localRead = await readLocalTeamMemory(state.serverMaxEntries) 684: const entries = localRead.entries 685: const skippedSecrets = localRead.skippedSecrets 686: if (skippedSecrets.length > 0) { 687: const summary = skippedSecrets 688: .map(s => `"${s.path}" (${s.label})`) 689: .join(', ') 690: logForDebugging( 691: `team-memory-sync: ${skippedSecrets.length} file(s) skipped due to detected secrets: ${summary}. Remove the secret(s) to enable sync for these files.`, 692: { level: 'warn' }, 693: ) 694: logEvent('tengu_team_mem_secret_skipped', { 695: file_count: skippedSecrets.length, 696: rule_ids: skippedSecrets 697: .map(s => s.ruleId) 698: .join( 699: ',', 700: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 701: }) 702: } 703: const localHashes = new Map<string, string>() 704: for (const [key, content] of Object.entries(entries)) { 705: localHashes.set(key, hashContent(content)) 706: } 707: let sawConflict = false 708: for ( 709: let conflictAttempt = 0; 710: conflictAttempt <= MAX_CONFLICT_RETRIES; 711: conflictAttempt++ 712: ) { 713: const delta: Record<string, string> = {} 714: for (const [key, localHash] of localHashes) { 715: if (state.serverChecksums.get(key) !== localHash) { 716: delta[key] = entries[key]! 717: } 718: } 719: const deltaCount = Object.keys(delta).length 720: if (deltaCount === 0) { 721: logPush(startTime, { 722: success: true, 723: conflict: sawConflict, 724: conflictRetries, 725: }) 726: return { 727: success: true, 728: filesUploaded: 0, 729: ...(skippedSecrets.length > 0 && { skippedSecrets }), 730: } 731: } 732: const batches = batchDeltaByBytes(delta) 733: let filesUploaded = 0 734: let result: TeamMemorySyncUploadResult | undefined 735: for (const batch of batches) { 736: result = await uploadTeamMemory( 737: state, 738: repoSlug, 739: batch, 740: state.lastKnownChecksum, 741: ) 742: if (!result.success) break 743: for (const key of Object.keys(batch)) { 744: state.serverChecksums.set(key, localHashes.get(key)!) 745: } 746: filesUploaded += Object.keys(batch).length 747: } 748: result = result! 749: if (result.success) { 750: logForDebugging( 751: batches.length > 1 752: ? `team-memory-sync: pushed ${filesUploaded} of ${localHashes.size} files in ${batches.length} batches` 753: : `team-memory-sync: pushed ${filesUploaded} of ${localHashes.size} files (delta)`, 754: { level: 'info' }, 755: ) 756: logPush(startTime, { 757: success: true, 758: filesUploaded, 759: conflict: sawConflict, 760: conflictRetries, 761: putBatches: batches.length > 1 ? batches.length : undefined, 762: }) 763: return { 764: success: true, 765: filesUploaded, 766: checksum: result.checksum, 767: ...(skippedSecrets.length > 0 && { skippedSecrets }), 768: } 769: } 770: if (!result.conflict) { 771: if (result.serverMaxEntries !== undefined) { 772: state.serverMaxEntries = result.serverMaxEntries 773: logForDebugging( 774: `team-memory-sync: learned server max_entries=${result.serverMaxEntries} from 413; next push will truncate to this`, 775: { level: 'warn' }, 776: ) 777: } 778: logPush(startTime, { 779: success: false, 780: filesUploaded, 781: conflictRetries, 782: putBatches: batches.length > 1 ? batches.length : undefined, 783: errorType: result.errorType, 784: status: result.httpStatus, 785: errorCode: result.serverErrorCode, 786: serverMaxEntries: result.serverMaxEntries, 787: serverReceivedEntries: result.serverReceivedEntries, 788: }) 789: return { 790: success: false, 791: filesUploaded, 792: error: result.error, 793: errorType: result.errorType, 794: httpStatus: result.httpStatus, 795: } 796: } 797: sawConflict = true 798: if (conflictAttempt >= MAX_CONFLICT_RETRIES) { 799: logForDebugging( 800: `team-memory-sync: giving up after ${MAX_CONFLICT_RETRIES} conflict retries`, 801: { level: 'warn' }, 802: ) 803: logPush(startTime, { 804: success: false, 805: conflict: true, 806: conflictRetries, 807: errorType: 'conflict', 808: }) 809: return { 810: success: false, 811: filesUploaded: 0, 812: conflict: true, 813: error: 'Conflict resolution failed after retries', 814: } 815: } 816: conflictRetries++ 817: logForDebugging( 818: `team-memory-sync: conflict (412), probing server hashes (attempt ${conflictAttempt + 1}/${MAX_CONFLICT_RETRIES})`, 819: { level: 'info' }, 820: ) 821: const probe = await fetchTeamMemoryHashes(state, repoSlug) 822: if (!probe.success || !probe.entryChecksums) { 823: logPush(startTime, { 824: success: false, 825: conflict: true, 826: conflictRetries, 827: errorType: 'conflict', 828: }) 829: return { 830: success: false, 831: filesUploaded: 0, 832: conflict: true, 833: error: `Conflict resolution hashes probe failed: ${probe.error}`, 834: } 835: } 836: state.serverChecksums.clear() 837: for (const [key, hash] of Object.entries(probe.entryChecksums)) { 838: state.serverChecksums.set(key, hash) 839: } 840: } 841: logPush(startTime, { success: false, conflictRetries }) 842: return { 843: success: false, 844: filesUploaded: 0, 845: error: 'Unexpected end of conflict resolution loop', 846: } 847: } 848: export async function syncTeamMemory(state: SyncState): Promise<{ 849: success: boolean 850: filesPulled: number 851: filesPushed: number 852: error?: string 853: }> { 854: const pullResult = await pullTeamMemory(state, { skipEtagCache: true }) 855: if (!pullResult.success) { 856: return { 857: success: false, 858: filesPulled: 0, 859: filesPushed: 0, 860: error: pullResult.error, 861: } 862: } 863: const pushResult = await pushTeamMemory(state) 864: if (!pushResult.success) { 865: return { 866: success: false, 867: filesPulled: pullResult.filesWritten, 868: filesPushed: 0, 869: error: pushResult.error, 870: } 871: } 872: logForDebugging( 873: `team-memory-sync: synced (pulled ${pullResult.filesWritten}, pushed ${pushResult.filesUploaded})`, 874: { level: 'info' }, 875: ) 876: return { 877: success: true, 878: filesPulled: pullResult.filesWritten, 879: filesPushed: pushResult.filesUploaded, 880: } 881: } 882: function logPull( 883: startTime: number, 884: outcome: { 885: success: boolean 886: filesWritten?: number 887: notModified?: boolean 888: errorType?: string 889: status?: number 890: }, 891: ): void { 892: logEvent('tengu_team_mem_sync_pull', { 893: success: outcome.success, 894: files_written: outcome.filesWritten ?? 0, 895: not_modified: outcome.notModified ?? false, 896: duration_ms: Date.now() - startTime, 897: ...(outcome.errorType && { 898: errorType: 899: outcome.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 900: }), 901: ...(outcome.status && { status: outcome.status }), 902: }) 903: } 904: function logPush( 905: startTime: number, 906: outcome: { 907: success: boolean 908: filesUploaded?: number 909: conflict?: boolean 910: conflictRetries?: number 911: errorType?: string 912: status?: number 913: putBatches?: number 914: errorCode?: string 915: serverMaxEntries?: number 916: serverReceivedEntries?: number 917: }, 918: ): void { 919: logEvent('tengu_team_mem_sync_push', { 920: success: outcome.success, 921: files_uploaded: outcome.filesUploaded ?? 0, 922: conflict: outcome.conflict ?? false, 923: conflict_retries: outcome.conflictRetries ?? 0, 924: duration_ms: Date.now() - startTime, 925: ...(outcome.errorType && { 926: errorType: 927: outcome.errorType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 928: }), 929: ...(outcome.status && { status: outcome.status }), 930: ...(outcome.putBatches && { put_batches: outcome.putBatches }), 931: ...(outcome.errorCode && { 932: error_code: 933: outcome.errorCode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 934: }), 935: ...(outcome.serverMaxEntries !== undefined && { 936: server_max_entries: outcome.serverMaxEntries, 937: }), 938: ...(outcome.serverReceivedEntries !== undefined && { 939: server_received_entries: outcome.serverReceivedEntries, 940: }), 941: }) 942: }

File: src/services/teamMemorySync/secretScanner.ts

typescript 1: import { capitalize } from '../../utils/stringUtils.js' 2: type SecretRule = { 3: id: string 4: source: string 5: flags?: string 6: } 7: export type SecretMatch = { 8: ruleId: string 9: label: string 10: } 11: const ANT_KEY_PFX = ['sk', 'ant', 'api'].join('-') 12: const SECRET_RULES: SecretRule[] = [ 13: { 14: id: 'aws-access-token', 15: source: '\\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\\b', 16: }, 17: { 18: id: 'gcp-api-key', 19: source: '\\b(AIza[\\w-]{35})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 20: }, 21: { 22: id: 'azure-ad-client-secret', 23: source: 24: '(?:^|[\\\\\'"\\x60\\s>=:(,)])([a-zA-Z0-9_~.]{3}\\dQ~[a-zA-Z0-9_~.-]{31,34})(?:$|[\\\\\'"\\x60\\s<),])', 25: }, 26: { 27: id: 'digitalocean-pat', 28: source: '\\b(dop_v1_[a-f0-9]{64})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 29: }, 30: { 31: id: 'digitalocean-access-token', 32: source: '\\b(doo_v1_[a-f0-9]{64})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 33: }, 34: { 35: id: 'anthropic-api-key', 36: source: `\\b(${ANT_KEY_PFX}03-[a-zA-Z0-9_\\-]{93}AA)(?:[\\x60'"\\s;]|\\\\[nr]|$)`, 37: }, 38: { 39: id: 'anthropic-admin-api-key', 40: source: 41: '\\b(sk-ant-admin01-[a-zA-Z0-9_\\-]{93}AA)(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 42: }, 43: { 44: id: 'openai-api-key', 45: source: 46: '\\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 47: }, 48: { 49: id: 'huggingface-access-token', 50: source: '\\b(hf_[a-zA-Z]{34})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 51: }, 52: { 53: id: 'github-pat', 54: source: 'ghp_[0-9a-zA-Z]{36}', 55: }, 56: { 57: id: 'github-fine-grained-pat', 58: source: 'github_pat_\\w{82}', 59: }, 60: { 61: id: 'github-app-token', 62: source: '(?:ghu|ghs)_[0-9a-zA-Z]{36}', 63: }, 64: { 65: id: 'github-oauth', 66: source: 'gho_[0-9a-zA-Z]{36}', 67: }, 68: { 69: id: 'github-refresh-token', 70: source: 'ghr_[0-9a-zA-Z]{36}', 71: }, 72: { 73: id: 'gitlab-pat', 74: source: 'glpat-[\\w-]{20}', 75: }, 76: { 77: id: 'gitlab-deploy-token', 78: source: 'gldt-[0-9a-zA-Z_\\-]{20}', 79: }, 80: { 81: id: 'slack-bot-token', 82: source: 'xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*', 83: }, 84: { 85: id: 'slack-user-token', 86: source: 'xox[pe](?:-[0-9]{10,13}){3}-[a-zA-Z0-9-]{28,34}', 87: }, 88: { 89: id: 'slack-app-token', 90: source: 'xapp-\\d-[A-Z0-9]+-\\d+-[a-z0-9]+', 91: flags: 'i', 92: }, 93: { 94: id: 'twilio-api-key', 95: source: 'SK[0-9a-fA-F]{32}', 96: }, 97: { 98: id: 'sendgrid-api-token', 99: source: '\\b(SG\\.[a-zA-Z0-9=_\\-.]{66})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 100: }, 101: { 102: id: 'npm-access-token', 103: source: '\\b(npm_[a-zA-Z0-9]{36})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 104: }, 105: { 106: id: 'pypi-upload-token', 107: source: 'pypi-AgEIcHlwaS5vcmc[\\w-]{50,1000}', 108: }, 109: { 110: id: 'databricks-api-token', 111: source: '\\b(dapi[a-f0-9]{32}(?:-\\d)?)(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 112: }, 113: { 114: id: 'hashicorp-tf-api-token', 115: source: '[a-zA-Z0-9]{14}\\.atlasv1\\.[a-zA-Z0-9\\-_=]{60,70}', 116: }, 117: { 118: id: 'pulumi-api-token', 119: source: '\\b(pul-[a-f0-9]{40})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 120: }, 121: { 122: id: 'postman-api-token', 123: source: 124: '\\b(PMAK-[a-fA-F0-9]{24}-[a-fA-F0-9]{34})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 125: }, 126: { 127: id: 'grafana-api-key', 128: source: 129: '\\b(eyJrIjoi[A-Za-z0-9+/]{70,400}={0,3})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 130: }, 131: { 132: id: 'grafana-cloud-api-token', 133: source: '\\b(glc_[A-Za-z0-9+/]{32,400}={0,3})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 134: }, 135: { 136: id: 'grafana-service-account-token', 137: source: 138: '\\b(glsa_[A-Za-z0-9]{32}_[A-Fa-f0-9]{8})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 139: }, 140: { 141: id: 'sentry-user-token', 142: source: '\\b(sntryu_[a-f0-9]{64})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 143: }, 144: { 145: id: 'sentry-org-token', 146: source: 147: '\\bsntrys_eyJpYXQiO[a-zA-Z0-9+/]{10,200}(?:LCJyZWdpb25fdXJs|InJlZ2lvbl91cmwi|cmVnaW9uX3VybCI6)[a-zA-Z0-9+/]{10,200}={0,2}_[a-zA-Z0-9+/]{43}', 148: }, 149: { 150: id: 'stripe-access-token', 151: source: 152: '\\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\\x60\'"\\s;]|\\\\[nr]|$)', 153: }, 154: { 155: id: 'shopify-access-token', 156: source: 'shpat_[a-fA-F0-9]{32}', 157: }, 158: { 159: id: 'shopify-shared-secret', 160: source: 'shpss_[a-fA-F0-9]{32}', 161: }, 162: { 163: id: 'private-key', 164: source: 165: '-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\\s\\S-]{64,}?-----END[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----', 166: flags: 'i', 167: }, 168: ] 169: let compiledRules: Array<{ id: string; re: RegExp }> | null = null 170: function getCompiledRules(): Array<{ id: string; re: RegExp }> { 171: if (compiledRules === null) { 172: compiledRules = SECRET_RULES.map(r => ({ 173: id: r.id, 174: re: new RegExp(r.source, r.flags), 175: })) 176: } 177: return compiledRules 178: } 179: function ruleIdToLabel(ruleId: string): string { 180: const specialCase: Record<string, string> = { 181: aws: 'AWS', 182: gcp: 'GCP', 183: api: 'API', 184: pat: 'PAT', 185: ad: 'AD', 186: tf: 'TF', 187: oauth: 'OAuth', 188: npm: 'NPM', 189: pypi: 'PyPI', 190: jwt: 'JWT', 191: github: 'GitHub', 192: gitlab: 'GitLab', 193: openai: 'OpenAI', 194: digitalocean: 'DigitalOcean', 195: huggingface: 'HuggingFace', 196: hashicorp: 'HashiCorp', 197: sendgrid: 'SendGrid', 198: } 199: return ruleId 200: .split('-') 201: .map(part => specialCase[part] ?? capitalize(part)) 202: .join(' ') 203: } 204: export function scanForSecrets(content: string): SecretMatch[] { 205: const matches: SecretMatch[] = [] 206: const seen = new Set<string>() 207: for (const rule of getCompiledRules()) { 208: if (seen.has(rule.id)) { 209: continue 210: } 211: if (rule.re.test(content)) { 212: seen.add(rule.id) 213: matches.push({ 214: ruleId: rule.id, 215: label: ruleIdToLabel(rule.id), 216: }) 217: } 218: } 219: return matches 220: } 221: export function getSecretLabel(ruleId: string): string { 222: return ruleIdToLabel(ruleId) 223: } 224: let redactRules: RegExp[] | null = null 225: export function redactSecrets(content: string): string { 226: redactRules ??= SECRET_RULES.map( 227: r => new RegExp(r.source, (r.flags ?? '').replace('g', '') + 'g'), 228: ) 229: for (const re of redactRules) { 230: content = content.replace(re, (match, g1) => 231: typeof g1 === 'string' ? match.replace(g1, '[REDACTED]') : '[REDACTED]', 232: ) 233: } 234: return content 235: }

File: src/services/teamMemorySync/teamMemSecretGuard.ts

typescript 1: import { feature } from 'bun:bundle' 2: export function checkTeamMemSecrets( 3: filePath: string, 4: content: string, 5: ): string | null { 6: if (feature('TEAMMEM')) { 7: const { isTeamMemPath } = 8: require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js') 9: const { scanForSecrets } = 10: require('./secretScanner.js') as typeof import('./secretScanner.js') 11: if (!isTeamMemPath(filePath)) { 12: return null 13: } 14: const matches = scanForSecrets(content) 15: if (matches.length === 0) { 16: return null 17: } 18: const labels = matches.map(m => m.label).join(', ') 19: return ( 20: `Content contains potential secrets (${labels}) and cannot be written to team memory. ` + 21: 'Team memory is shared with all repository collaborators. ' + 22: 'Remove the sensitive content and try again.' 23: ) 24: } 25: return null 26: }

File: src/services/teamMemorySync/types.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../../utils/lazySchema.js' 3: export const TeamMemoryContentSchema = lazySchema(() => 4: z.object({ 5: entries: z.record(z.string(), z.string()), 6: entryChecksums: z.record(z.string(), z.string()).optional(), 7: }), 8: ) 9: export const TeamMemoryDataSchema = lazySchema(() => 10: z.object({ 11: organizationId: z.string(), 12: repo: z.string(), 13: version: z.number(), 14: lastModified: z.string(), 15: checksum: z.string(), 16: content: TeamMemoryContentSchema(), 17: }), 18: ) 19: export const TeamMemoryTooManyEntriesSchema = lazySchema(() => 20: z.object({ 21: error: z.object({ 22: details: z.object({ 23: error_code: z.literal('team_memory_too_many_entries'), 24: max_entries: z.number().int().positive(), 25: received_entries: z.number().int().positive(), 26: }), 27: }), 28: }), 29: ) 30: export type TeamMemoryData = z.infer<ReturnType<typeof TeamMemoryDataSchema>> 31: export type SkippedSecretFile = { 32: path: string 33: ruleId: string 34: label: string 35: } 36: export type TeamMemorySyncFetchResult = { 37: success: boolean 38: data?: TeamMemoryData 39: isEmpty?: boolean 40: notModified?: boolean 41: checksum?: string 42: error?: string 43: skipRetry?: boolean 44: errorType?: 'auth' | 'timeout' | 'network' | 'parse' | 'unknown' 45: httpStatus?: number 46: } 47: export type TeamMemoryHashesResult = { 48: success: boolean 49: version?: number 50: checksum?: string 51: entryChecksums?: Record<string, string> 52: error?: string 53: errorType?: 'auth' | 'timeout' | 'network' | 'parse' | 'unknown' 54: httpStatus?: number 55: } 56: export type TeamMemorySyncPushResult = { 57: success: boolean 58: filesUploaded: number 59: checksum?: string 60: conflict?: boolean 61: error?: string 62: skippedSecrets?: SkippedSecretFile[] 63: errorType?: 64: | 'auth' 65: | 'timeout' 66: | 'network' 67: | 'conflict' 68: | 'unknown' 69: | 'no_oauth' 70: | 'no_repo' 71: httpStatus?: number 72: } 73: export type TeamMemorySyncUploadResult = { 74: success: boolean 75: checksum?: string 76: lastModified?: string 77: conflict?: boolean 78: error?: string 79: errorType?: 'auth' | 'timeout' | 'network' | 'unknown' 80: httpStatus?: number 81: serverErrorCode?: 'team_memory_too_many_entries' 82: serverMaxEntries?: number 83: serverReceivedEntries?: number 84: }

File: src/services/teamMemorySync/watcher.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { type FSWatcher, watch } from 'fs' 3: import { mkdir, stat } from 'fs/promises' 4: import { join } from 'path' 5: import { 6: getTeamMemPath, 7: isTeamMemoryEnabled, 8: } from '../../memdir/teamMemPaths.js' 9: import { registerCleanup } from '../../utils/cleanupRegistry.js' 10: import { logForDebugging } from '../../utils/debug.js' 11: import { errorMessage } from '../../utils/errors.js' 12: import { getGithubRepo } from '../../utils/git.js' 13: import { 14: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 15: logEvent, 16: } from '../analytics/index.js' 17: import { 18: createSyncState, 19: isTeamMemorySyncAvailable, 20: pullTeamMemory, 21: pushTeamMemory, 22: type SyncState, 23: } from './index.js' 24: import type { TeamMemorySyncPushResult } from './types.js' 25: const DEBOUNCE_MS = 2000 26: let watcher: FSWatcher | null = null 27: let debounceTimer: ReturnType<typeof setTimeout> | null = null 28: let pushInProgress = false 29: let hasPendingChanges = false 30: let currentPushPromise: Promise<void> | null = null 31: let watcherStarted = false 32: let pushSuppressedReason: string | null = null 33: export function isPermanentFailure(r: TeamMemorySyncPushResult): boolean { 34: if (r.errorType === 'no_oauth' || r.errorType === 'no_repo') return true 35: if ( 36: r.httpStatus !== undefined && 37: r.httpStatus >= 400 && 38: r.httpStatus < 500 && 39: r.httpStatus !== 409 && 40: r.httpStatus !== 429 41: ) { 42: return true 43: } 44: return false 45: } 46: let syncState: SyncState | null = null 47: async function executePush(): Promise<void> { 48: if (!syncState) { 49: return 50: } 51: pushInProgress = true 52: try { 53: const result = await pushTeamMemory(syncState) 54: if (result.success) { 55: hasPendingChanges = false 56: } 57: if (result.success && result.filesUploaded > 0) { 58: logForDebugging( 59: `team-memory-watcher: pushed ${result.filesUploaded} files`, 60: { level: 'info' }, 61: ) 62: } else if (!result.success) { 63: logForDebugging(`team-memory-watcher: push failed: ${result.error}`, { 64: level: 'warn', 65: }) 66: if (isPermanentFailure(result) && pushSuppressedReason === null) { 67: pushSuppressedReason = 68: result.httpStatus !== undefined 69: ? `http_${result.httpStatus}` 70: : (result.errorType ?? 'unknown') 71: logForDebugging( 72: `team-memory-watcher: suppressing retry until next unlink or session restart (${pushSuppressedReason})`, 73: { level: 'warn' }, 74: ) 75: logEvent('tengu_team_mem_push_suppressed', { 76: reason: 77: pushSuppressedReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 78: ...(result.httpStatus && { status: result.httpStatus }), 79: }) 80: } 81: } 82: } catch (e) { 83: logForDebugging(`team-memory-watcher: push error: ${errorMessage(e)}`, { 84: level: 'warn', 85: }) 86: } finally { 87: pushInProgress = false 88: currentPushPromise = null 89: } 90: } 91: function schedulePush(): void { 92: if (pushSuppressedReason !== null) return 93: hasPendingChanges = true 94: if (debounceTimer) { 95: clearTimeout(debounceTimer) 96: } 97: debounceTimer = setTimeout(() => { 98: if (pushInProgress) { 99: schedulePush() 100: return 101: } 102: currentPushPromise = executePush() 103: }, DEBOUNCE_MS) 104: } 105: async function startFileWatcher(teamDir: string): Promise<void> { 106: if (watcherStarted) { 107: return 108: } 109: watcherStarted = true 110: try { 111: await mkdir(teamDir, { recursive: true }) 112: watcher = watch( 113: teamDir, 114: { persistent: true, recursive: true }, 115: (_eventType, filename) => { 116: if (filename === null) { 117: schedulePush() 118: return 119: } 120: if (pushSuppressedReason !== null) { 121: void stat(join(teamDir, filename)).catch( 122: (err: NodeJS.ErrnoException) => { 123: if (err.code !== 'ENOENT') return 124: if (pushSuppressedReason !== null) { 125: logForDebugging( 126: `team-memory-watcher: unlink cleared suppression (was: ${pushSuppressedReason})`, 127: { level: 'info' }, 128: ) 129: pushSuppressedReason = null 130: } 131: schedulePush() 132: }, 133: ) 134: return 135: } 136: schedulePush() 137: }, 138: ) 139: watcher.on('error', err => { 140: logForDebugging( 141: `team-memory-watcher: fs.watch error: ${errorMessage(err)}`, 142: { level: 'warn' }, 143: ) 144: }) 145: logForDebugging(`team-memory-watcher: watching ${teamDir}`, { 146: level: 'debug', 147: }) 148: } catch (err) { 149: logForDebugging( 150: `team-memory-watcher: failed to watch ${teamDir}: ${errorMessage(err)}`, 151: { level: 'warn' }, 152: ) 153: } 154: registerCleanup(async () => stopTeamMemoryWatcher()) 155: } 156: export async function startTeamMemoryWatcher(): Promise<void> { 157: if (!feature('TEAMMEM')) { 158: return 159: } 160: if (!isTeamMemoryEnabled() || !isTeamMemorySyncAvailable()) { 161: return 162: } 163: const repoSlug = await getGithubRepo() 164: if (!repoSlug) { 165: logForDebugging( 166: 'team-memory-watcher: no github.com remote, skipping sync', 167: { level: 'debug' }, 168: ) 169: return 170: } 171: syncState = createSyncState() 172: let initialPullSuccess = false 173: let initialFilesPulled = 0 174: let serverHasContent = false 175: try { 176: const pullResult = await pullTeamMemory(syncState) 177: initialPullSuccess = pullResult.success 178: serverHasContent = pullResult.entryCount > 0 179: if (pullResult.success && pullResult.filesWritten > 0) { 180: initialFilesPulled = pullResult.filesWritten 181: logForDebugging( 182: `team-memory-watcher: initial pull got ${pullResult.filesWritten} files`, 183: { level: 'info' }, 184: ) 185: } 186: } catch (e) { 187: logForDebugging( 188: `team-memory-watcher: initial pull failed: ${errorMessage(e)}`, 189: { level: 'warn' }, 190: ) 191: } 192: await startFileWatcher(getTeamMemPath()) 193: logEvent('tengu_team_mem_sync_started', { 194: initial_pull_success: initialPullSuccess, 195: initial_files_pulled: initialFilesPulled, 196: watcher_started: true, 197: server_has_content: serverHasContent, 198: }) 199: } 200: export async function notifyTeamMemoryWrite(): Promise<void> { 201: if (!syncState) { 202: return 203: } 204: schedulePush() 205: } 206: export async function stopTeamMemoryWatcher(): Promise<void> { 207: if (debounceTimer) { 208: clearTimeout(debounceTimer) 209: debounceTimer = null 210: } 211: if (watcher) { 212: watcher.close() 213: watcher = null 214: } 215: if (currentPushPromise) { 216: try { 217: await currentPushPromise 218: } catch { 219: } 220: } 221: if (hasPendingChanges && syncState && pushSuppressedReason === null) { 222: try { 223: await pushTeamMemory(syncState) 224: } catch { 225: } 226: } 227: } 228: export function _resetWatcherStateForTesting(opts?: { 229: syncState?: SyncState 230: skipWatcher?: boolean 231: pushSuppressedReason?: string | null 232: }): void { 233: watcher = null 234: debounceTimer = null 235: pushInProgress = false 236: hasPendingChanges = false 237: currentPushPromise = null 238: watcherStarted = opts?.skipWatcher ?? false 239: pushSuppressedReason = opts?.pushSuppressedReason ?? null 240: syncState = opts?.syncState ?? null 241: } 242: export function _startFileWatcherForTesting(dir: string): Promise<void> { 243: return startFileWatcher(dir) 244: }

File: src/services/tips/tipHistory.ts

typescript 1: import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' 2: export function recordTipShown(tipId: string): void { 3: const numStartups = getGlobalConfig().numStartups 4: saveGlobalConfig(c => { 5: const history = c.tipsHistory ?? {} 6: if (history[tipId] === numStartups) return c 7: return { ...c, tipsHistory: { ...history, [tipId]: numStartups } } 8: }) 9: } 10: export function getSessionsSinceLastShown(tipId: string): number { 11: const config = getGlobalConfig() 12: const lastShown = config.tipsHistory?.[tipId] 13: if (!lastShown) return Infinity 14: return config.numStartups - lastShown 15: }

File: src/services/tips/tipRegistry.ts

typescript 1: import chalk from 'chalk' 2: import { logForDebugging } from 'src/utils/debug.js' 3: import { fileHistoryEnabled } from 'src/utils/fileHistory.js' 4: import { 5: getInitialSettings, 6: getSettings_DEPRECATED, 7: getSettingsForSource, 8: } from 'src/utils/settings/settings.js' 9: import { shouldOfferTerminalSetup } from '../../commands/terminalSetup/terminalSetup.js' 10: import { getDesktopUpsellConfig } from '../../components/DesktopUpsell/DesktopUpsellStartup.js' 11: import { color } from '../../components/design-system/color.js' 12: import { shouldShowOverageCreditUpsell } from '../../components/LogoV2/OverageCreditUpsell.js' 13: import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' 14: import { isKairosCronEnabled } from '../../tools/ScheduleCronTool/prompt.js' 15: import { is1PApiCustomer } from '../../utils/auth.js' 16: import { countConcurrentSessions } from '../../utils/concurrentSessions.js' 17: import { getGlobalConfig } from '../../utils/config.js' 18: import { 19: getEffortEnvOverride, 20: modelSupportsEffort, 21: } from '../../utils/effort.js' 22: import { env } from '../../utils/env.js' 23: import { cacheKeys } from '../../utils/fileStateCache.js' 24: import { getWorktreeCount } from '../../utils/git.js' 25: import { 26: detectRunningIDEsCached, 27: getSortedIdeLockfiles, 28: isCursorInstalled, 29: isSupportedTerminal, 30: isSupportedVSCodeTerminal, 31: isVSCodeInstalled, 32: isWindsurfInstalled, 33: } from '../../utils/ide.js' 34: import { 35: getMainLoopModel, 36: getUserSpecifiedModelSetting, 37: } from '../../utils/model/model.js' 38: import { getPlatform } from '../../utils/platform.js' 39: import { isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js' 40: import { loadKnownMarketplacesConfigSafe } from '../../utils/plugins/marketplaceManager.js' 41: import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js' 42: import { 43: getCurrentSessionAgentColor, 44: isCustomTitleEnabled, 45: } from '../../utils/sessionStorage.js' 46: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../analytics/growthbook.js' 47: import { 48: formatGrantAmount, 49: getCachedOverageCreditGrant, 50: } from '../api/overageCreditGrant.js' 51: import { 52: checkCachedPassesEligibility, 53: formatCreditAmount, 54: getCachedReferrerReward, 55: } from '../api/referral.js' 56: import { getSessionsSinceLastShown } from './tipHistory.js' 57: import type { Tip, TipContext } from './types.js' 58: let _isOfficialMarketplaceInstalledCache: boolean | undefined 59: async function isOfficialMarketplaceInstalled(): Promise<boolean> { 60: if (_isOfficialMarketplaceInstalledCache !== undefined) { 61: return _isOfficialMarketplaceInstalledCache 62: } 63: const config = await loadKnownMarketplacesConfigSafe() 64: _isOfficialMarketplaceInstalledCache = OFFICIAL_MARKETPLACE_NAME in config 65: return _isOfficialMarketplaceInstalledCache 66: } 67: async function isMarketplacePluginRelevant( 68: pluginName: string, 69: context: TipContext | undefined, 70: signals: { filePath?: RegExp; cli?: string[] }, 71: ): Promise<boolean> { 72: if (!(await isOfficialMarketplaceInstalled())) { 73: return false 74: } 75: if (isPluginInstalled(`${pluginName}@${OFFICIAL_MARKETPLACE_NAME}`)) { 76: return false 77: } 78: const { bashTools } = context ?? {} 79: if (signals.cli && bashTools?.size) { 80: if (signals.cli.some(cmd => bashTools.has(cmd))) { 81: return true 82: } 83: } 84: if (signals.filePath && context?.readFileState) { 85: const readFiles = cacheKeys(context.readFileState) 86: if (readFiles.some(fp => signals.filePath!.test(fp))) { 87: return true 88: } 89: } 90: return false 91: } 92: const externalTips: Tip[] = [ 93: { 94: id: 'new-user-warmup', 95: content: async () => 96: `Start with small features or bug fixes, tell Claude to propose a plan, and verify its suggested edits`, 97: cooldownSessions: 3, 98: async isRelevant() { 99: const config = getGlobalConfig() 100: return config.numStartups < 10 101: }, 102: }, 103: { 104: id: 'plan-mode-for-complex-tasks', 105: content: async () => 106: `Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`, 107: cooldownSessions: 5, 108: isRelevant: async () => { 109: if (process.env.USER_TYPE === 'ant') return false 110: const config = getGlobalConfig() 111: const daysSinceLastUse = config.lastPlanModeUse 112: ? (Date.now() - config.lastPlanModeUse) / (1000 * 60 * 60 * 24) 113: : Infinity 114: return daysSinceLastUse > 7 115: }, 116: }, 117: { 118: id: 'default-permission-mode-config', 119: content: async () => 120: `Use /config to change your default permission mode (including Plan Mode)`, 121: cooldownSessions: 10, 122: isRelevant: async () => { 123: try { 124: const config = getGlobalConfig() 125: const settings = getSettings_DEPRECATED() 126: const hasUsedPlanMode = Boolean(config.lastPlanModeUse) 127: const hasDefaultMode = Boolean(settings?.permissions?.defaultMode) 128: return hasUsedPlanMode && !hasDefaultMode 129: } catch (error) { 130: logForDebugging( 131: `Failed to check default-permission-mode-config tip relevance: ${error}`, 132: { level: 'warn' }, 133: ) 134: return false 135: } 136: }, 137: }, 138: { 139: id: 'git-worktrees', 140: content: async () => 141: 'Use git worktrees to run multiple Claude sessions in parallel.', 142: cooldownSessions: 10, 143: isRelevant: async () => { 144: try { 145: const config = getGlobalConfig() 146: const worktreeCount = await getWorktreeCount() 147: return worktreeCount <= 1 && config.numStartups > 50 148: } catch (_) { 149: return false 150: } 151: }, 152: }, 153: { 154: id: 'color-when-multi-clauding', 155: content: async () => 156: 'Running multiple Claude sessions? Use /color and /rename to tell them apart at a glance.', 157: cooldownSessions: 10, 158: isRelevant: async () => { 159: if (getCurrentSessionAgentColor()) return false 160: const count = await countConcurrentSessions() 161: return count >= 2 162: }, 163: }, 164: { 165: id: 'terminal-setup', 166: content: async () => 167: env.terminal === 'Apple_Terminal' 168: ? 'Run /terminal-setup to enable convenient terminal integration like Option + Enter for new line and more' 169: : 'Run /terminal-setup to enable convenient terminal integration like Shift + Enter for new line and more', 170: cooldownSessions: 10, 171: async isRelevant() { 172: const config = getGlobalConfig() 173: if (env.terminal === 'Apple_Terminal') { 174: return !config.optionAsMetaKeyInstalled 175: } 176: return !config.shiftEnterKeyBindingInstalled 177: }, 178: }, 179: { 180: id: 'shift-enter', 181: content: async () => 182: env.terminal === 'Apple_Terminal' 183: ? 'Press Option+Enter to send a multi-line message' 184: : 'Press Shift+Enter to send a multi-line message', 185: cooldownSessions: 10, 186: async isRelevant() { 187: const config = getGlobalConfig() 188: return Boolean( 189: (env.terminal === 'Apple_Terminal' 190: ? config.optionAsMetaKeyInstalled 191: : config.shiftEnterKeyBindingInstalled) && config.numStartups > 3, 192: ) 193: }, 194: }, 195: { 196: id: 'shift-enter-setup', 197: content: async () => 198: env.terminal === 'Apple_Terminal' 199: ? 'Run /terminal-setup to enable Option+Enter for new lines' 200: : 'Run /terminal-setup to enable Shift+Enter for new lines', 201: cooldownSessions: 10, 202: async isRelevant() { 203: if (!shouldOfferTerminalSetup()) { 204: return false 205: } 206: const config = getGlobalConfig() 207: return !(env.terminal === 'Apple_Terminal' 208: ? config.optionAsMetaKeyInstalled 209: : config.shiftEnterKeyBindingInstalled) 210: }, 211: }, 212: { 213: id: 'memory-command', 214: content: async () => 'Use /memory to view and manage Claude memory', 215: cooldownSessions: 15, 216: async isRelevant() { 217: const config = getGlobalConfig() 218: return config.memoryUsageCount <= 0 219: }, 220: }, 221: { 222: id: 'theme-command', 223: content: async () => 'Use /theme to change the color theme', 224: cooldownSessions: 20, 225: isRelevant: async () => true, 226: }, 227: { 228: id: 'colorterm-truecolor', 229: content: async () => 230: 'Try setting environment variable COLORTERM=truecolor for richer colors', 231: cooldownSessions: 30, 232: isRelevant: async () => !process.env.COLORTERM && chalk.level < 3, 233: }, 234: { 235: id: 'powershell-tool-env', 236: content: async () => 237: 'Set CLAUDE_CODE_USE_POWERSHELL_TOOL=1 to enable the PowerShell tool (preview)', 238: cooldownSessions: 10, 239: isRelevant: async () => 240: getPlatform() === 'windows' && 241: process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL === undefined, 242: }, 243: { 244: id: 'status-line', 245: content: async () => 246: 'Use /statusline to set up a custom status line that will display beneath the input box', 247: cooldownSessions: 25, 248: isRelevant: async () => getSettings_DEPRECATED().statusLine === undefined, 249: }, 250: { 251: id: 'prompt-queue', 252: content: async () => 253: 'Hit Enter to queue up additional messages while Claude is working.', 254: cooldownSessions: 5, 255: async isRelevant() { 256: const config = getGlobalConfig() 257: return config.promptQueueUseCount <= 3 258: }, 259: }, 260: { 261: id: 'enter-to-steer-in-relatime', 262: content: async () => 263: 'Send messages to Claude while it works to steer Claude in real-time', 264: cooldownSessions: 20, 265: isRelevant: async () => true, 266: }, 267: { 268: id: 'todo-list', 269: content: async () => 270: 'Ask Claude to create a todo list when working on complex tasks to track progress and remain on track', 271: cooldownSessions: 20, 272: isRelevant: async () => true, 273: }, 274: { 275: id: 'vscode-command-install', 276: content: async () => 277: `Open the Command Palette (Cmd+Shift+P) and run "Shell Command: Install '${env.terminal === 'vscode' ? 'code' : env.terminal}' command in PATH" to enable IDE integration`, 278: cooldownSessions: 0, 279: async isRelevant() { 280: if (!isSupportedVSCodeTerminal()) { 281: return false 282: } 283: if (getPlatform() !== 'macos') { 284: return false 285: } 286: switch (env.terminal) { 287: case 'vscode': 288: return !(await isVSCodeInstalled()) 289: case 'cursor': 290: return !(await isCursorInstalled()) 291: case 'windsurf': 292: return !(await isWindsurfInstalled()) 293: default: 294: return false 295: } 296: }, 297: }, 298: { 299: id: 'ide-upsell-external-terminal', 300: content: async () => 'Connect Claude to your IDE · /ide', 301: cooldownSessions: 4, 302: async isRelevant() { 303: if (isSupportedTerminal()) { 304: return false 305: } 306: const lockfiles = await getSortedIdeLockfiles() 307: if (lockfiles.length !== 0) { 308: return false 309: } 310: const runningIDEs = await detectRunningIDEsCached() 311: return runningIDEs.length > 0 312: }, 313: }, 314: { 315: id: 'install-github-app', 316: content: async () => 317: 'Run /install-github-app to tag @claude right from your Github issues and PRs', 318: cooldownSessions: 10, 319: isRelevant: async () => !getGlobalConfig().githubActionSetupCount, 320: }, 321: { 322: id: 'install-slack-app', 323: content: async () => 'Run /install-slack-app to use Claude in Slack', 324: cooldownSessions: 10, 325: isRelevant: async () => !getGlobalConfig().slackAppInstallCount, 326: }, 327: { 328: id: 'permissions', 329: content: async () => 330: 'Use /permissions to pre-approve and pre-deny bash, edit, and MCP tools', 331: cooldownSessions: 10, 332: async isRelevant() { 333: const config = getGlobalConfig() 334: return config.numStartups > 10 335: }, 336: }, 337: { 338: id: 'drag-and-drop-images', 339: content: async () => 340: 'Did you know you can drag and drop image files into your terminal?', 341: cooldownSessions: 10, 342: isRelevant: async () => !env.isSSH(), 343: }, 344: { 345: id: 'paste-images-mac', 346: content: async () => 347: 'Paste images into Claude Code using control+v (not cmd+v!)', 348: cooldownSessions: 10, 349: isRelevant: async () => getPlatform() === 'macos', 350: }, 351: { 352: id: 'double-esc', 353: content: async () => 354: 'Double-tap esc to rewind the conversation to a previous point in time', 355: cooldownSessions: 10, 356: isRelevant: async () => !fileHistoryEnabled(), 357: }, 358: { 359: id: 'double-esc-code-restore', 360: content: async () => 361: 'Double-tap esc to rewind the code and/or conversation to a previous point in time', 362: cooldownSessions: 10, 363: isRelevant: async () => fileHistoryEnabled(), 364: }, 365: { 366: id: 'continue', 367: content: async () => 368: 'Run claude --continue or claude --resume to resume a conversation', 369: cooldownSessions: 10, 370: isRelevant: async () => true, 371: }, 372: { 373: id: 'rename-conversation', 374: content: async () => 375: 'Name your conversations with /rename to find them easily in /resume later', 376: cooldownSessions: 15, 377: isRelevant: async () => 378: isCustomTitleEnabled() && getGlobalConfig().numStartups > 10, 379: }, 380: { 381: id: 'custom-commands', 382: content: async () => 383: 'Create skills by adding .md files to .claude/skills/ in your project or ~/.claude/skills/ for skills that work in any project', 384: cooldownSessions: 15, 385: async isRelevant() { 386: const config = getGlobalConfig() 387: return config.numStartups > 10 388: }, 389: }, 390: { 391: id: 'shift-tab', 392: content: async () => 393: process.env.USER_TYPE === 'ant' 394: ? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode` 395: : `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`, 396: cooldownSessions: 10, 397: isRelevant: async () => true, 398: }, 399: { 400: id: 'image-paste', 401: content: async () => 402: `Use ${getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')} to paste images from your clipboard`, 403: cooldownSessions: 20, 404: isRelevant: async () => true, 405: }, 406: { 407: id: 'custom-agents', 408: content: async () => 409: 'Use /agents to optimize specific tasks. Eg. Software Architect, Code Writer, Code Reviewer', 410: cooldownSessions: 15, 411: async isRelevant() { 412: const config = getGlobalConfig() 413: return config.numStartups > 5 414: }, 415: }, 416: { 417: id: 'agent-flag', 418: content: async () => 419: 'Use --agent <agent_name> to directly start a conversation with a subagent', 420: cooldownSessions: 15, 421: async isRelevant() { 422: const config = getGlobalConfig() 423: return config.numStartups > 5 424: }, 425: }, 426: { 427: id: 'desktop-app', 428: content: async () => 429: 'Run Claude Code locally or remotely using the Claude desktop app: clau.de/desktop', 430: cooldownSessions: 15, 431: isRelevant: async () => getPlatform() !== 'linux', 432: }, 433: { 434: id: 'desktop-shortcut', 435: content: async ctx => { 436: const blue = color('suggestion', ctx.theme) 437: return `Continue your session in Claude Code Desktop with ${blue('/desktop')}` 438: }, 439: cooldownSessions: 15, 440: isRelevant: async () => { 441: if (!getDesktopUpsellConfig().enable_shortcut_tip) return false 442: return ( 443: process.platform === 'darwin' || 444: (process.platform === 'win32' && process.arch === 'x64') 445: ) 446: }, 447: }, 448: { 449: id: 'web-app', 450: content: async () => 451: 'Run tasks in the cloud while you keep coding locally · clau.de/web', 452: cooldownSessions: 15, 453: isRelevant: async () => true, 454: }, 455: { 456: id: 'mobile-app', 457: content: async () => 458: '/mobile to use Claude Code from the Claude app on your phone', 459: cooldownSessions: 15, 460: isRelevant: async () => true, 461: }, 462: { 463: id: 'opusplan-mode-reminder', 464: content: async () => 465: `Your default model setting is Opus Plan Mode. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to activate Plan Mode and plan with Claude Opus.`, 466: cooldownSessions: 2, 467: async isRelevant() { 468: if (process.env.USER_TYPE === 'ant') return false 469: const config = getGlobalConfig() 470: const modelSetting = getUserSpecifiedModelSetting() 471: const hasOpusPlanMode = modelSetting === 'opusplan' 472: const daysSinceLastUse = config.lastPlanModeUse 473: ? (Date.now() - config.lastPlanModeUse) / (1000 * 60 * 60 * 24) 474: : Infinity 475: return hasOpusPlanMode && daysSinceLastUse > 3 476: }, 477: }, 478: { 479: id: 'frontend-design-plugin', 480: content: async ctx => { 481: const blue = color('suggestion', ctx.theme) 482: return `Working with HTML/CSS? Install the frontend-design plugin:\n${blue(`/plugin install frontend-design@${OFFICIAL_MARKETPLACE_NAME}`)}` 483: }, 484: cooldownSessions: 3, 485: isRelevant: async context => 486: isMarketplacePluginRelevant('frontend-design', context, { 487: filePath: /\.(html|css|htm)$/i, 488: }), 489: }, 490: { 491: id: 'vercel-plugin', 492: content: async ctx => { 493: const blue = color('suggestion', ctx.theme) 494: return `Working with Vercel? Install the vercel plugin:\n${blue(`/plugin install vercel@${OFFICIAL_MARKETPLACE_NAME}`)}` 495: }, 496: cooldownSessions: 3, 497: isRelevant: async context => 498: isMarketplacePluginRelevant('vercel', context, { 499: filePath: /(?:^|[/\\])vercel\.json$/i, 500: cli: ['vercel'], 501: }), 502: }, 503: { 504: id: 'effort-high-nudge', 505: content: async ctx => { 506: const blue = color('suggestion', ctx.theme) 507: const cmd = blue('/effort high') 508: const variant = getFeatureValue_CACHED_MAY_BE_STALE< 509: 'off' | 'copy_a' | 'copy_b' 510: >('tengu_tide_elm', 'off') 511: return variant === 'copy_b' 512: ? `Use ${cmd} for better one-shot answers. Claude thinks it through first.` 513: : `Working on something tricky? ${cmd} gives better first answers` 514: }, 515: cooldownSessions: 3, 516: isRelevant: async () => { 517: if (!is1PApiCustomer()) return false 518: if (!modelSupportsEffort(getMainLoopModel())) return false 519: if (getSettingsForSource('policySettings')?.effortLevel !== undefined) { 520: return false 521: } 522: if (getEffortEnvOverride() !== undefined) return false 523: const persisted = getInitialSettings().effortLevel 524: if (persisted === 'high' || persisted === 'max') return false 525: return ( 526: getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>( 527: 'tengu_tide_elm', 528: 'off', 529: ) !== 'off' 530: ) 531: }, 532: }, 533: { 534: id: 'subagent-fanout-nudge', 535: content: async ctx => { 536: const blue = color('suggestion', ctx.theme) 537: const variant = getFeatureValue_CACHED_MAY_BE_STALE< 538: 'off' | 'copy_a' | 'copy_b' 539: >('tengu_tern_alloy', 'off') 540: return variant === 'copy_b' 541: ? `For big tasks, tell Claude to ${blue('use subagents')}. They work in parallel and keep your main thread clean.` 542: : `Say ${blue('"fan out subagents"')} and Claude sends a team. Each one digs deep so nothing gets missed.` 543: }, 544: cooldownSessions: 3, 545: isRelevant: async () => { 546: if (!is1PApiCustomer()) return false 547: return ( 548: getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>( 549: 'tengu_tern_alloy', 550: 'off', 551: ) !== 'off' 552: ) 553: }, 554: }, 555: { 556: id: 'loop-command-nudge', 557: content: async ctx => { 558: const blue = color('suggestion', ctx.theme) 559: const variant = getFeatureValue_CACHED_MAY_BE_STALE< 560: 'off' | 'copy_a' | 'copy_b' 561: >('tengu_timber_lark', 'off') 562: return variant === 'copy_b' 563: ? `Use ${blue('/loop 5m check the deploy')} to run any prompt on a schedule. Set it and forget it.` 564: : `${blue('/loop')} runs any prompt on a recurring schedule. Great for monitoring deploys, babysitting PRs, or polling status.` 565: }, 566: cooldownSessions: 3, 567: isRelevant: async () => { 568: if (!is1PApiCustomer()) return false 569: if (!isKairosCronEnabled()) return false 570: return ( 571: getFeatureValue_CACHED_MAY_BE_STALE<'off' | 'copy_a' | 'copy_b'>( 572: 'tengu_timber_lark', 573: 'off', 574: ) !== 'off' 575: ) 576: }, 577: }, 578: { 579: id: 'guest-passes', 580: content: async ctx => { 581: const claude = color('claude', ctx.theme) 582: const reward = getCachedReferrerReward() 583: return reward 584: ? `Share Claude Code and earn ${claude(formatCreditAmount(reward))} of extra usage · ${claude('/passes')}` 585: : `You have free guest passes to share · ${claude('/passes')}` 586: }, 587: cooldownSessions: 3, 588: isRelevant: async () => { 589: const config = getGlobalConfig() 590: if (config.hasVisitedPasses) { 591: return false 592: } 593: const { eligible } = checkCachedPassesEligibility() 594: return eligible 595: }, 596: }, 597: { 598: id: 'overage-credit', 599: content: async ctx => { 600: const claude = color('claude', ctx.theme) 601: const info = getCachedOverageCreditGrant() 602: const amount = info ? formatGrantAmount(info) : null 603: if (!amount) return '' 604: // Copy from "OC & Bulk Overages copy" doc (#5 — CLI Rotating tip) 605: return `${claude(`${amount} in extra usage, on us`)} · third-party apps · ${claude('/extra-usage')}` 606: }, 607: cooldownSessions: 3, 608: isRelevant: async () => shouldShowOverageCreditUpsell(), 609: }, 610: { 611: id: 'feedback-command', 612: content: async () => 'Use /feedback to help us improve!', 613: cooldownSessions: 15, 614: async isRelevant() { 615: if (process.env.USER_TYPE === 'ant') { 616: return false 617: } 618: const config = getGlobalConfig() 619: return config.numStartups > 5 620: }, 621: }, 622: ] 623: const internalOnlyTips: Tip[] = 624: process.env.USER_TYPE === 'ant' 625: ? [ 626: { 627: id: 'important-claudemd', 628: content: async () => 629: '[ANT-ONLY] Use "IMPORTANT:" prefix for must-follow CLAUDE.md rules', 630: cooldownSessions: 30, 631: isRelevant: async () => true, 632: }, 633: { 634: id: 'skillify', 635: content: async () => 636: '[ANT-ONLY] Use /skillify at the end of a workflow to turn it into a reusable skill', 637: cooldownSessions: 15, 638: isRelevant: async () => true, 639: }, 640: ] 641: : [] 642: function getCustomTips(): Tip[] { 643: const settings = getInitialSettings() 644: const override = settings.spinnerTipsOverride 645: if (!override?.tips?.length) return [] 646: return override.tips.map((content, i) => ({ 647: id: `custom-tip-${i}`, 648: content: async () => content, 649: cooldownSessions: 0, 650: isRelevant: async () => true, 651: })) 652: } 653: export async function getRelevantTips(context?: TipContext): Promise<Tip[]> { 654: const settings = getInitialSettings() 655: const override = settings.spinnerTipsOverride 656: const customTips = getCustomTips() 657: if (override?.excludeDefault && customTips.length > 0) { 658: return customTips 659: } 660: const tips = [...externalTips, ...internalOnlyTips] 661: const isRelevant = await Promise.all(tips.map(_ => _.isRelevant(context))) 662: const filtered = tips 663: .filter((_, index) => isRelevant[index]) 664: .filter(_ => getSessionsSinceLastShown(_.id) >= _.cooldownSessions) 665: return [...filtered, ...customTips] 666: }

File: src/services/tips/tipScheduler.ts

typescript 1: import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' 2: import { 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: logEvent, 5: } from '../analytics/index.js' 6: import { getSessionsSinceLastShown, recordTipShown } from './tipHistory.js' 7: import { getRelevantTips } from './tipRegistry.js' 8: import type { Tip, TipContext } from './types.js' 9: export function selectTipWithLongestTimeSinceShown( 10: availableTips: Tip[], 11: ): Tip | undefined { 12: if (availableTips.length === 0) { 13: return undefined 14: } 15: if (availableTips.length === 1) { 16: return availableTips[0] 17: } 18: const tipsWithSessions = availableTips.map(tip => ({ 19: tip, 20: sessions: getSessionsSinceLastShown(tip.id), 21: })) 22: tipsWithSessions.sort((a, b) => b.sessions - a.sessions) 23: return tipsWithSessions[0]?.tip 24: } 25: export async function getTipToShowOnSpinner( 26: context?: TipContext, 27: ): Promise<Tip | undefined> { 28: if (getSettings_DEPRECATED().spinnerTipsEnabled === false) { 29: return undefined 30: } 31: const tips = await getRelevantTips(context) 32: if (tips.length === 0) { 33: return undefined 34: } 35: return selectTipWithLongestTimeSinceShown(tips) 36: } 37: export function recordShownTip(tip: Tip): void { 38: recordTipShown(tip.id) 39: logEvent('tengu_tip_shown', { 40: tipIdLength: 41: tip.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 42: cooldownSessions: tip.cooldownSessions, 43: }) 44: }

File: src/services/tools/StreamingToolExecutor.ts

typescript 1: import type { ToolUseBlock } from '@anthropic-ai/sdk/resources/index.mjs' 2: import { 3: createUserMessage, 4: REJECT_MESSAGE, 5: withMemoryCorrectionHint, 6: } from 'src/utils/messages.js' 7: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 8: import { findToolByName, type Tools, type ToolUseContext } from '../../Tool.js' 9: import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 10: import type { AssistantMessage, Message } from '../../types/message.js' 11: import { createChildAbortController } from '../../utils/abortController.js' 12: import { runToolUse } from './toolExecution.js' 13: type MessageUpdate = { 14: message?: Message 15: newContext?: ToolUseContext 16: } 17: type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded' 18: type TrackedTool = { 19: id: string 20: block: ToolUseBlock 21: assistantMessage: AssistantMessage 22: status: ToolStatus 23: isConcurrencySafe: boolean 24: promise?: Promise<void> 25: results?: Message[] 26: pendingProgress: Message[] 27: contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext> 28: } 29: export class StreamingToolExecutor { 30: private tools: TrackedTool[] = [] 31: private toolUseContext: ToolUseContext 32: private hasErrored = false 33: private erroredToolDescription = '' 34: // Child of toolUseContext.abortController. Fires when a Bash tool errors 35: // so sibling subprocesses die immediately instead of running to completion. 36: // Aborting this does NOT abort the parent — query.ts won't end the turn. 37: private siblingAbortController: AbortController 38: private discarded = false 39: private progressAvailableResolve?: () => void 40: constructor( 41: private readonly toolDefinitions: Tools, 42: private readonly canUseTool: CanUseToolFn, 43: toolUseContext: ToolUseContext, 44: ) { 45: this.toolUseContext = toolUseContext 46: this.siblingAbortController = createChildAbortController( 47: toolUseContext.abortController, 48: ) 49: } 50: discard(): void { 51: this.discarded = true 52: } 53: addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { 54: const toolDefinition = findToolByName(this.toolDefinitions, block.name) 55: if (!toolDefinition) { 56: this.tools.push({ 57: id: block.id, 58: block, 59: assistantMessage, 60: status: 'completed', 61: isConcurrencySafe: true, 62: pendingProgress: [], 63: results: [ 64: createUserMessage({ 65: content: [ 66: { 67: type: 'tool_result', 68: content: `<tool_use_error>Error: No such tool available: ${block.name}</tool_use_error>`, 69: is_error: true, 70: tool_use_id: block.id, 71: }, 72: ], 73: toolUseResult: `Error: No such tool available: ${block.name}`, 74: sourceToolAssistantUUID: assistantMessage.uuid, 75: }), 76: ], 77: }) 78: return 79: } 80: const parsedInput = toolDefinition.inputSchema.safeParse(block.input) 81: const isConcurrencySafe = parsedInput?.success 82: ? (() => { 83: try { 84: return Boolean(toolDefinition.isConcurrencySafe(parsedInput.data)) 85: } catch { 86: return false 87: } 88: })() 89: : false 90: this.tools.push({ 91: id: block.id, 92: block, 93: assistantMessage, 94: status: 'queued', 95: isConcurrencySafe, 96: pendingProgress: [], 97: }) 98: void this.processQueue() 99: } 100: private canExecuteTool(isConcurrencySafe: boolean): boolean { 101: const executingTools = this.tools.filter(t => t.status === 'executing') 102: return ( 103: executingTools.length === 0 || 104: (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe)) 105: ) 106: } 107: private async processQueue(): Promise<void> { 108: for (const tool of this.tools) { 109: if (tool.status !== 'queued') continue 110: if (this.canExecuteTool(tool.isConcurrencySafe)) { 111: await this.executeTool(tool) 112: } else { 113: if (!tool.isConcurrencySafe) break 114: } 115: } 116: } 117: private createSyntheticErrorMessage( 118: toolUseId: string, 119: reason: 'sibling_error' | 'user_interrupted' | 'streaming_fallback', 120: assistantMessage: AssistantMessage, 121: ): Message { 122: if (reason === 'user_interrupted') { 123: return createUserMessage({ 124: content: [ 125: { 126: type: 'tool_result', 127: content: withMemoryCorrectionHint(REJECT_MESSAGE), 128: is_error: true, 129: tool_use_id: toolUseId, 130: }, 131: ], 132: toolUseResult: 'User rejected tool use', 133: sourceToolAssistantUUID: assistantMessage.uuid, 134: }) 135: } 136: if (reason === 'streaming_fallback') { 137: return createUserMessage({ 138: content: [ 139: { 140: type: 'tool_result', 141: content: 142: '<tool_use_error>Error: Streaming fallback - tool execution discarded</tool_use_error>', 143: is_error: true, 144: tool_use_id: toolUseId, 145: }, 146: ], 147: toolUseResult: 'Streaming fallback - tool execution discarded', 148: sourceToolAssistantUUID: assistantMessage.uuid, 149: }) 150: } 151: const desc = this.erroredToolDescription 152: const msg = desc 153: ? `Cancelled: parallel tool call ${desc} errored` 154: : 'Cancelled: parallel tool call errored' 155: return createUserMessage({ 156: content: [ 157: { 158: type: 'tool_result', 159: content: `<tool_use_error>${msg}</tool_use_error>`, 160: is_error: true, 161: tool_use_id: toolUseId, 162: }, 163: ], 164: toolUseResult: msg, 165: sourceToolAssistantUUID: assistantMessage.uuid, 166: }) 167: } 168: private getAbortReason( 169: tool: TrackedTool, 170: ): 'sibling_error' | 'user_interrupted' | 'streaming_fallback' | null { 171: if (this.discarded) { 172: return 'streaming_fallback' 173: } 174: if (this.hasErrored) { 175: return 'sibling_error' 176: } 177: if (this.toolUseContext.abortController.signal.aborted) { 178: if (this.toolUseContext.abortController.signal.reason === 'interrupt') { 179: return this.getToolInterruptBehavior(tool) === 'cancel' 180: ? 'user_interrupted' 181: : null 182: } 183: return 'user_interrupted' 184: } 185: return null 186: } 187: private getToolInterruptBehavior(tool: TrackedTool): 'cancel' | 'block' { 188: const definition = findToolByName(this.toolDefinitions, tool.block.name) 189: if (!definition?.interruptBehavior) return 'block' 190: try { 191: return definition.interruptBehavior() 192: } catch { 193: return 'block' 194: } 195: } 196: private getToolDescription(tool: TrackedTool): string { 197: const input = tool.block.input as Record<string, unknown> | undefined 198: const summary = input?.command ?? input?.file_path ?? input?.pattern ?? '' 199: if (typeof summary === 'string' && summary.length > 0) { 200: const truncated = 201: summary.length > 40 ? summary.slice(0, 40) + '\u2026' : summary 202: return `${tool.block.name}(${truncated})` 203: } 204: return tool.block.name 205: } 206: private updateInterruptibleState(): void { 207: const executing = this.tools.filter(t => t.status === 'executing') 208: this.toolUseContext.setHasInterruptibleToolInProgress?.( 209: executing.length > 0 && 210: executing.every(t => this.getToolInterruptBehavior(t) === 'cancel'), 211: ) 212: } 213: private async executeTool(tool: TrackedTool): Promise<void> { 214: tool.status = 'executing' 215: this.toolUseContext.setInProgressToolUseIDs(prev => 216: new Set(prev).add(tool.id), 217: ) 218: this.updateInterruptibleState() 219: const messages: Message[] = [] 220: const contextModifiers: Array<(context: ToolUseContext) => ToolUseContext> = 221: [] 222: const collectResults = async () => { 223: const initialAbortReason = this.getAbortReason(tool) 224: if (initialAbortReason) { 225: messages.push( 226: this.createSyntheticErrorMessage( 227: tool.id, 228: initialAbortReason, 229: tool.assistantMessage, 230: ), 231: ) 232: tool.results = messages 233: tool.contextModifiers = contextModifiers 234: tool.status = 'completed' 235: this.updateInterruptibleState() 236: return 237: } 238: const toolAbortController = createChildAbortController( 239: this.siblingAbortController, 240: ) 241: toolAbortController.signal.addEventListener( 242: 'abort', 243: () => { 244: if ( 245: toolAbortController.signal.reason !== 'sibling_error' && 246: !this.toolUseContext.abortController.signal.aborted && 247: !this.discarded 248: ) { 249: this.toolUseContext.abortController.abort( 250: toolAbortController.signal.reason, 251: ) 252: } 253: }, 254: { once: true }, 255: ) 256: const generator = runToolUse( 257: tool.block, 258: tool.assistantMessage, 259: this.canUseTool, 260: { ...this.toolUseContext, abortController: toolAbortController }, 261: ) 262: let thisToolErrored = false 263: for await (const update of generator) { 264: const abortReason = this.getAbortReason(tool) 265: if (abortReason && !thisToolErrored) { 266: messages.push( 267: this.createSyntheticErrorMessage( 268: tool.id, 269: abortReason, 270: tool.assistantMessage, 271: ), 272: ) 273: break 274: } 275: const isErrorResult = 276: update.message.type === 'user' && 277: Array.isArray(update.message.message.content) && 278: update.message.message.content.some( 279: _ => _.type === 'tool_result' && _.is_error === true, 280: ) 281: if (isErrorResult) { 282: thisToolErrored = true 283: if (tool.block.name === BASH_TOOL_NAME) { 284: this.hasErrored = true 285: this.erroredToolDescription = this.getToolDescription(tool) 286: this.siblingAbortController.abort('sibling_error') 287: } 288: } 289: if (update.message) { 290: if (update.message.type === 'progress') { 291: tool.pendingProgress.push(update.message) 292: if (this.progressAvailableResolve) { 293: this.progressAvailableResolve() 294: this.progressAvailableResolve = undefined 295: } 296: } else { 297: messages.push(update.message) 298: } 299: } 300: if (update.contextModifier) { 301: contextModifiers.push(update.contextModifier.modifyContext) 302: } 303: } 304: tool.results = messages 305: tool.contextModifiers = contextModifiers 306: tool.status = 'completed' 307: this.updateInterruptibleState() 308: if (!tool.isConcurrencySafe && contextModifiers.length > 0) { 309: for (const modifier of contextModifiers) { 310: this.toolUseContext = modifier(this.toolUseContext) 311: } 312: } 313: } 314: const promise = collectResults() 315: tool.promise = promise 316: void promise.finally(() => { 317: void this.processQueue() 318: }) 319: } 320: *getCompletedResults(): Generator<MessageUpdate, void> { 321: if (this.discarded) { 322: return 323: } 324: for (const tool of this.tools) { 325: while (tool.pendingProgress.length > 0) { 326: const progressMessage = tool.pendingProgress.shift()! 327: yield { message: progressMessage, newContext: this.toolUseContext } 328: } 329: if (tool.status === 'yielded') { 330: continue 331: } 332: if (tool.status === 'completed' && tool.results) { 333: tool.status = 'yielded' 334: for (const message of tool.results) { 335: yield { message, newContext: this.toolUseContext } 336: } 337: markToolUseAsComplete(this.toolUseContext, tool.id) 338: } else if (tool.status === 'executing' && !tool.isConcurrencySafe) { 339: break 340: } 341: } 342: } 343: private hasPendingProgress(): boolean { 344: return this.tools.some(t => t.pendingProgress.length > 0) 345: } 346: async *getRemainingResults(): AsyncGenerator<MessageUpdate, void> { 347: if (this.discarded) { 348: return 349: } 350: while (this.hasUnfinishedTools()) { 351: await this.processQueue() 352: for (const result of this.getCompletedResults()) { 353: yield result 354: } 355: if ( 356: this.hasExecutingTools() && 357: !this.hasCompletedResults() && 358: !this.hasPendingProgress() 359: ) { 360: const executingPromises = this.tools 361: .filter(t => t.status === 'executing' && t.promise) 362: .map(t => t.promise!) 363: const progressPromise = new Promise<void>(resolve => { 364: this.progressAvailableResolve = resolve 365: }) 366: if (executingPromises.length > 0) { 367: await Promise.race([...executingPromises, progressPromise]) 368: } 369: } 370: } 371: for (const result of this.getCompletedResults()) { 372: yield result 373: } 374: } 375: private hasCompletedResults(): boolean { 376: return this.tools.some(t => t.status === 'completed') 377: } 378: private hasExecutingTools(): boolean { 379: return this.tools.some(t => t.status === 'executing') 380: } 381: private hasUnfinishedTools(): boolean { 382: return this.tools.some(t => t.status !== 'yielded') 383: } 384: getUpdatedContext(): ToolUseContext { 385: return this.toolUseContext 386: } 387: } 388: function markToolUseAsComplete( 389: toolUseContext: ToolUseContext, 390: toolUseID: string, 391: ) { 392: toolUseContext.setInProgressToolUseIDs(prev => { 393: const next = new Set(prev) 394: next.delete(toolUseID) 395: return next 396: }) 397: }

File: src/services/tools/toolExecution.ts

````typescript 1: import { feature } from ‘bun:bundle’ 2: import type { 3: ContentBlockParam, 4: ToolResultBlockParam, 5: ToolUseBlock, 6: } from ‘@anthropic-ai/sdk/resources/index.mjs’ 7: import { 8: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9: logEvent, 10: } from ‘src/services/analytics/index.js’ 11: import { 12: extractMcpToolDetails, 13: extractSkillName, 14: extractToolInputForTelemetry, 15: getFileExtensionForAnalytics, 16: getFileExtensionsFromBashCommand, 17: isToolDetailsLoggingEnabled, 18: mcpToolDetailsForAnalytics, 19: sanitizeToolNameForAnalytics, 20: } from ‘src/services/analytics/metadata.js’ 21: import { 22: addToToolDuration, 23: getCodeEditToolDecisionCounter, 24: getStatsStore, 25: } from ‘../../bootstrap/state.js’ 26: import { 27: buildCodeEditToolAttributes, 28: isCodeEditingTool, 29: } from ‘../../hooks/toolPermission/permissionLogging.js’ 30: import type { CanUseToolFn } from ‘../../hooks/useCanUseTool.js’ 31: import { 32: findToolByName, 33: type Tool, 34: type ToolProgress, 35: type ToolProgressData, 36: type ToolUseContext, 37: } from ‘../../Tool.js’ 38: import type { BashToolInput } from ‘../../tools/BashTool/BashTool.js’ 39: import { startSpeculativeClassifierCheck } from ‘../../tools/BashTool/bashPermissions.js’ 40: import { BASH_TOOL_NAME } from ‘../../tools/BashTool/toolName.js’ 41: import { FILE_EDIT_TOOL_NAME } from ‘../../tools/FileEditTool/constants.js’ 42: import { FILE_READ_TOOL_NAME } from ‘../../tools/FileReadTool/prompt.js’ 43: import { FILE_WRITE_TOOL_NAME } from ‘../../tools/FileWriteTool/prompt.js’ 44: import { NOTEBOOK_EDIT_TOOL_NAME } from ‘../../tools/NotebookEditTool/constants.js’ 45: import { POWERSHELL_TOOL_NAME } from ‘../../tools/PowerShellTool/toolName.js’ 46: import { parseGitCommitId } from ‘../../tools/shared/gitOperationTracking.js’ 47: import { 48: isDeferredTool, 49: TOOL_SEARCH_TOOL_NAME, 50: } from ‘../../tools/ToolSearchTool/prompt.js’ 51: import { getAllBaseTools } from ‘../../tools.js’ 52: import type { HookProgress } from ‘../../types/hooks.js’ 53: import type { 54: AssistantMessage, 55: AttachmentMessage, 56: Message, 57: ProgressMessage, 58: StopHookInfo, 59: } from ‘../../types/message.js’ 60: import { count } from ‘../../utils/array.js’ 61: import { createAttachmentMessage } from ‘../../utils/attachments.js’ 62: import { logForDebugging } from ‘../../utils/debug.js’ 63: import { 64: AbortError, 65: errorMessage, 66: getErrnoCode, 67: ShellError, 68: TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 69: } from ‘../../utils/errors.js’ 70: import { executePermissionDeniedHooks } from ‘../../utils/hooks.js’ 71: import { logError } from ‘../../utils/log.js’ 72: import { 73: CANCEL_MESSAGE, 74: createProgressMessage, 75: createStopHookSummaryMessage, 76: createToolResultStopMessage, 77: createUserMessage, 78: withMemoryCorrectionHint, 79: } from ‘../../utils/messages.js’ 80: import type { 81: PermissionDecisionReason, 82: PermissionResult, 83: } from ‘../../utils/permissions/PermissionResult.js’ 84: import { 85: startSessionActivity, 86: stopSessionActivity, 87: } from ‘../../utils/sessionActivity.js’ 88: import { jsonStringify } from ‘../../utils/slowOperations.js’ 89: import { Stream } from ‘../../utils/stream.js’ 90: import { logOTelEvent } from ‘../../utils/telemetry/events.js’ 91: import { 92: addToolContentEvent, 93: endToolBlockedOnUserSpan, 94: endToolExecutionSpan, 95: endToolSpan, 96: isBetaTracingEnabled, 97: startToolBlockedOnUserSpan, 98: startToolExecutionSpan, 99: startToolSpan, 100: } from ‘../../utils/telemetry/sessionTracing.js’ 101: import { 102: formatError, 103: formatZodValidationError, 104: } from ‘../../utils/toolErrors.js’ 105: import { 106: processPreMappedToolResultBlock, 107: processToolResultBlock, 108: } from ‘../../utils/toolResultStorage.js’ 109: import { 110: extractDiscoveredToolNames, 111: isToolSearchEnabledOptimistic, 112: isToolSearchToolAvailable, 113: } from ‘../../utils/toolSearch.js’ 114: import { 115: McpAuthError, 116: McpToolCallError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 117: } from ‘../mcp/client.js’ 118: import { mcpInfoFromString } from ‘../mcp/mcpStringUtils.js’ 119: import { normalizeNameForMCP } from ‘../mcp/normalization.js’ 120: import type { MCPServerConnection } from ‘../mcp/types.js’ 121: import { 122: getLoggingSafeMcpBaseUrl, 123: getMcpServerScopeFromToolName, 124: isMcpTool, 125: } from ‘../mcp/utils.js’ 126: import { 127: resolveHookPermissionDecision, 128: runPostToolUseFailureHooks, 129: runPostToolUseHooks, 130: runPreToolUseHooks, 131: } from ‘./toolHooks.js’ 132: export const HOOK_TIMING_DISPLAY_THRESHOLD_MS = 500 133: const SLOW_PHASE_LOG_THRESHOLD_MS = 2000 134: export function classifyToolError(error: unknown): string { 135: if ( 136: error instanceof TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 137: ) { 138: return error.telemetryMessage.slice(0, 200) 139: }