流出したclaude codeのソースコード4
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: }