mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-22 08:15:53 +00:00
feat: 工具层及 mcp 大重构 (#252)
* feat: 第一版大重构 * fix: 修复类型问题 * chore: 更新版本到 1.3.2 * Add brave as alternative WebSearchTool * fix: 修正顺序 * fix: 修复对穷鬼模式的 auto dream 和 session memory 越过 * feat: 穷鬼模式去除 session-summary * feat: 创建 builtin-tools 包,搬运所有工具实现 将 src/tools/ 下的全部 60 个工具目录迁移至 packages/builtin-tools/src/tools/, 内部导入路径已更新为 src/ alias 模式。 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 更新 src/ 中所有工具引用至 builtin-tools 包,删除 src/tools/ - src/tools.ts 及 178 个 src/ 文件的 import 路径从 ./tools/ 改为 builtin-tools/tools/ - 删除 src/tools/ 整个目录(已迁移至 packages/builtin-tools/) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: 添加 builtin-tools 路径别名至 tsconfig,更新 bun.lock - tsconfig.json 新增 builtin-tools/* 和 builtin-tools 路径映射 - 新增 packages/builtin-tools/src 至 include Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: 为 builtin-tools、mcp-client、agent-tools 添加 @claude-code-best 作用域前缀 所有包名及 import 路径统一添加 @claude-code-best/ 前缀: - builtin-tools → @claude-code-best/builtin-tools - mcp-client → @claude-code-best/mcp-client - agent-tools → @claude-code-best/agent-tools Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: 修复 node 环境没有 bun 的问题 --------- Co-authored-by: Eric-Guo <eric.guocz@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
176
packages/@ant/ink/docs/01-getting-started.md
Normal file
176
packages/@ant/ink/docs/01-getting-started.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# Chapter 1: Getting Started
|
||||
|
||||
## Installation
|
||||
|
||||
`@anthropic/ink` is a workspace package. It is consumed internally and not published to npm.
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"@anthropic/ink": "workspace:*"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Peer Dependencies
|
||||
|
||||
- `react` ^19.2.4
|
||||
- `react-reconciler` ^0.33.0
|
||||
|
||||
### Key Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `chalk` | ANSI color generation |
|
||||
| `cli-boxes` | Border style definitions |
|
||||
| `get-east-asian-width` | CJK character width measurement |
|
||||
| `wrap-ansi` | ANSI-aware word wrapping |
|
||||
| `bidi-js` | Bidirectional text support |
|
||||
| `lodash-es` | Utility functions (throttle, noop) |
|
||||
| `signal-exit` | Process exit handler cleanup |
|
||||
| `emoji-regex` | Emoji width handling |
|
||||
|
||||
## Basic Rendering
|
||||
|
||||
### `render(node, options?)`
|
||||
|
||||
The primary entry point. Renders a React element tree to the terminal.
|
||||
|
||||
```tsx
|
||||
import { render } from '@anthropic/ink'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
const { unmount, rerender, waitUntilExit } = await render(
|
||||
<Box>
|
||||
<Text>Hello, World!</Text>
|
||||
</Box>
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters:**
|
||||
- `node` -- `ReactNode` to render
|
||||
- `options` -- `RenderOptions | NodeJS.WriteStream` (optional)
|
||||
|
||||
**Returns:** `Promise<Instance>` with:
|
||||
- `rerender(node)` -- Replace the root node
|
||||
- `unmount()` -- Unmount and clean up
|
||||
- `waitUntilExit()` -- `Promise<void>` that resolves on unmount
|
||||
- `cleanup()` -- Remove from instance registry
|
||||
|
||||
### `renderSync(node, options?)`
|
||||
|
||||
Synchronous version of render. Same API, returns `Instance` directly (no Promise).
|
||||
|
||||
```tsx
|
||||
import { renderSync } from '@anthropic/ink'
|
||||
|
||||
const instance = renderSync(<App />)
|
||||
// instance.rerender, instance.unmount, etc.
|
||||
```
|
||||
|
||||
### `createRoot(options?)`
|
||||
|
||||
Creates a managed Ink root without immediately rendering. Similar to `react-dom`'s `createRoot`.
|
||||
|
||||
```tsx
|
||||
import { createRoot } from '@anthropic/ink'
|
||||
|
||||
const root = await createRoot({ exitOnCtrlC: false })
|
||||
|
||||
// Later, render into it
|
||||
root.render(<App />)
|
||||
|
||||
// You can re-render into the same root
|
||||
root.render(<DifferentApp />)
|
||||
|
||||
// Clean up
|
||||
root.unmount()
|
||||
```
|
||||
|
||||
**Returns:** `Promise<Root>` with:
|
||||
- `render(node)` -- Mount or update the tree
|
||||
- `unmount()` -- Unmount
|
||||
- `waitUntilExit()` -- `Promise<void>`
|
||||
|
||||
## RenderOptions
|
||||
|
||||
```ts
|
||||
type RenderOptions = {
|
||||
/** Output stream. Default: process.stdout */
|
||||
stdout?: NodeJS.WriteStream
|
||||
|
||||
/** Input stream. Default: process.stdin */
|
||||
stdin?: NodeJS.ReadStream
|
||||
|
||||
/** Error stream. Default: process.stderr */
|
||||
stderr?: NodeJS.WriteStream
|
||||
|
||||
/** Handle Ctrl+C to exit. Default: true */
|
||||
exitOnCtrlC?: boolean
|
||||
|
||||
/** Patch console methods to prevent Ink output mixing. Default: true */
|
||||
patchConsole?: boolean
|
||||
|
||||
/** Called after each frame render with timing info. */
|
||||
onFrame?: (event: FrameEvent) => void
|
||||
}
|
||||
```
|
||||
|
||||
## Basic Concepts
|
||||
|
||||
### Component Tree
|
||||
|
||||
Ink renders React components to a terminal using a custom reconciler. The tree structure maps to terminal output:
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="column">
|
||||
<Text bold color="green">Header</Text>
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text>Left</Text>
|
||||
<Text>Right</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
This produces terminal output with Flexbox layout (via Yoga).
|
||||
|
||||
### Rendering Pipeline
|
||||
|
||||
1. **React Reconciler** -- Standard React reconciliation; diffs virtual tree
|
||||
2. **Yoga Layout** -- Computes Flexbox positions/ sizes for every node
|
||||
3. **Render to Output** -- Walks the DOM tree, emits styled text into an `Output` buffer
|
||||
4. **Screen Diff** -- Compares new frame against previous frame in a screen buffer
|
||||
5. **Terminal Write** -- Emits minimal ANSI escape sequences to update only changed cells
|
||||
|
||||
### Module System
|
||||
|
||||
Import everything from the package root:
|
||||
|
||||
```tsx
|
||||
// Core rendering
|
||||
import { render, createRoot, renderSync } from '@anthropic/ink'
|
||||
|
||||
// Components (base, no theme)
|
||||
import { BaseBox, BaseText, ScrollBox, Button, Link, Newline, Spacer } from '@anthropic/ink'
|
||||
|
||||
// Theme-aware components (recommended)
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
// Hooks
|
||||
import { useApp, useInput, useTerminalSize, useInterval } from '@anthropic/ink'
|
||||
|
||||
// Theme
|
||||
import { ThemeProvider, useTheme, color } from '@anthropic/ink'
|
||||
|
||||
// Keybindings
|
||||
import { useKeybinding, KeybindingProvider } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Naming Convention: Base vs Theme-aware
|
||||
|
||||
The package exports both raw and theme-aware versions of core components:
|
||||
|
||||
- **`BaseBox`** / **`BaseText`** -- Raw components that only accept raw color values (`rgb(...)`, `#hex`, `ansi:...`, `ansi256(...)`)
|
||||
- **`Box`** / **`Text`** -- Theme-aware wrappers that accept both theme keys (`'claude'`, `'success'`, `'error'`) and raw color values
|
||||
|
||||
Always prefer the theme-aware versions unless you have a specific reason to use raw components.
|
||||
348
packages/@ant/ink/docs/02-layout.md
Normal file
348
packages/@ant/ink/docs/02-layout.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# Chapter 2: Layout System
|
||||
|
||||
Ink uses [Yoga](https://yogalayout.com/) (Facebook's cross-platform layout engine) to implement CSS Flexbox in the terminal. Every layout is flexbox-based -- there is no CSS Grid or flow layout.
|
||||
|
||||
## Box Component
|
||||
|
||||
`Box` is the fundamental layout primitive. It is the terminal equivalent of `<div style="display: flex">`.
|
||||
|
||||
```tsx
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
|
||||
<Box flexDirection="row" gap={1}>
|
||||
<Text>Left</Text>
|
||||
<Text>Right</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Box Props (Styles)
|
||||
|
||||
All layout props are passed directly as JSX props (no `style={}` wrapper needed):
|
||||
|
||||
#### Flex Direction
|
||||
|
||||
Controls the main axis direction.
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row">...</Box> // Left to right (default)
|
||||
<Box flexDirection="column">...</Box> // Top to bottom
|
||||
<Box flexDirection="row-reverse">...</Box> // Right to left
|
||||
<Box flexDirection="column-reverse">...</Box> // Bottom to top
|
||||
```
|
||||
|
||||
#### Flex Grow / Shrink / Basis
|
||||
|
||||
```tsx
|
||||
<Box flexGrow={1}>...</Box> // Grow to fill available space
|
||||
<Box flexShrink={0}>...</Box> // Don't shrink below intrinsic size
|
||||
<Box flexBasis={20}>...</Box> // Initial size before flex distribution
|
||||
<Box flexBasis="50%">...</Box> // Percentage basis
|
||||
```
|
||||
|
||||
Default values: `flexGrow={0}`, `flexShrink={1}`, `flexBasis=auto`.
|
||||
|
||||
#### Flex Wrap
|
||||
|
||||
```tsx
|
||||
<Box flexWrap="nowrap">...</Box> // Single line (default)
|
||||
<Box flexWrap="wrap">...</Box> // Multiple lines
|
||||
<Box flexWrap="wrap-reverse">...</Box> // Reverse cross-axis stacking
|
||||
```
|
||||
|
||||
#### Alignment
|
||||
|
||||
```tsx
|
||||
<Box alignItems="flex-start">...</Box> // Cross-axis start
|
||||
<Box alignItems="center">...</Box> // Cross-axis center
|
||||
<Box alignItems="flex-end">...</Box> // Cross-axis end
|
||||
<Box alignItems="stretch">...</Box> // Stretch to fill (default)
|
||||
|
||||
<Box alignSelf="flex-start">...</Box> // Override parent's alignItems
|
||||
<Box alignSelf="center">...</Box>
|
||||
<Box alignSelf="flex-end">...</Box>
|
||||
<Box alignSelf="auto">...</Box> // Inherit from parent
|
||||
```
|
||||
|
||||
#### Justify Content
|
||||
|
||||
```tsx
|
||||
<Box justifyContent="flex-start">...</Box> // Main-axis start (default)
|
||||
<Box justifyContent="flex-end">...</Box> // Main-axis end
|
||||
<Box justifyContent="center">...</Box> // Center
|
||||
<Box justifyContent="space-between">...</Box> // Equal gaps, no edges
|
||||
<Box justifyContent="space-around">...</Box> // Equal gaps with edges
|
||||
<Box justifyContent="space-evenly">...</Box> // Evenly distributed
|
||||
```
|
||||
|
||||
#### Gap
|
||||
|
||||
Spacing between children (only accepts integers):
|
||||
|
||||
```tsx
|
||||
<Box gap={1}>...</Box> // Both row and column gap
|
||||
<Box columnGap={2}>...</Box> // Gap between columns only
|
||||
<Box rowGap={1}>...</Box> // Gap between rows only
|
||||
```
|
||||
|
||||
#### Padding
|
||||
|
||||
Inner spacing (only accepts integers):
|
||||
|
||||
```tsx
|
||||
<Box padding={1}>...</Box> // All sides
|
||||
<Box paddingX={2}>...</Box> // Left and right
|
||||
<Box paddingY={1}>...</Box> // Top and bottom
|
||||
<Box paddingLeft={2}>...</Box> // Left only
|
||||
<Box paddingRight={2}>...</Box> // Right only
|
||||
<Box paddingTop={1}>...</Box> // Top only
|
||||
<Box paddingBottom={1}>...</Box> // Bottom only
|
||||
```
|
||||
|
||||
#### Margin
|
||||
|
||||
Outer spacing (only accepts integers):
|
||||
|
||||
```tsx
|
||||
<Box margin={1}>...</Box> // All sides
|
||||
<Box marginX={2}>...</Box> // Left and right
|
||||
<Box marginY={1}>...</Box> // Top and bottom
|
||||
<Box marginLeft={2}>...</Box> // Left only
|
||||
<Box marginRight={2}>...</Box> // Right only
|
||||
<Box marginTop={1}>...</Box> // Top only
|
||||
<Box marginBottom={1}>...</Box> // Bottom only
|
||||
```
|
||||
|
||||
> **Note:** Fractional values for padding, margin, and gap are not supported. Ink will emit warnings if non-integer values are used.
|
||||
|
||||
#### Width & Height
|
||||
|
||||
```tsx
|
||||
<Box width={40}>...</Box> // Fixed 40 characters wide
|
||||
<Box height={10}>...</Box> // Fixed 10 rows tall
|
||||
<Box width="50%">...</Box> // 50% of parent's width
|
||||
<Box width="100%">...</Box> // Full parent width
|
||||
```
|
||||
|
||||
#### Min/Max Dimensions
|
||||
|
||||
```tsx
|
||||
<Box minWidth={20}>...</Box>
|
||||
<Box maxWidth={80}>...</Box>
|
||||
<Box minHeight={5}>...</Box>
|
||||
<Box maxHeight={20}>...</Box>
|
||||
```
|
||||
|
||||
Percentage values are supported: `minWidth="30%"`.
|
||||
|
||||
#### Position
|
||||
|
||||
```tsx
|
||||
<Box position="absolute" top={0} right={0}>...</Box>
|
||||
<Box position="absolute" top="10%" left="20%">...</Box>
|
||||
<Box position="relative">...</Box> // Default
|
||||
```
|
||||
|
||||
Position `absolute` removes the element from normal flow and positions it relative to its nearest positioned ancestor. Useful for overlays.
|
||||
|
||||
#### Display
|
||||
|
||||
```tsx
|
||||
<Box display="flex">...</Box> // Visible (default)
|
||||
<Box display="none">...</Box> // Hidden (removed from layout)
|
||||
```
|
||||
|
||||
#### Border
|
||||
|
||||
```tsx
|
||||
<Box borderStyle="single">...</Box> // Thin border
|
||||
<Box borderStyle="double">...</Box> // Double-line border
|
||||
<Box borderStyle="round">...</Box> // Rounded corners
|
||||
<Box borderStyle="bold">...</Box> // Bold border
|
||||
<Box borderStyle="singleDouble">...</Box> // Mixed
|
||||
<Box borderStyle="doubleSingle">...</Box> // Mixed
|
||||
<Box borderStyle="classic">...</Box> // ASCII art border
|
||||
```
|
||||
|
||||
Control individual sides and colors:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTop={false} // Hide top border
|
||||
borderBottom={true} // Show bottom border
|
||||
borderColor="rgb(255,0,0)" // Red border
|
||||
borderDimColor={true} // Dim the border
|
||||
>
|
||||
...
|
||||
</Box>
|
||||
```
|
||||
|
||||
Per-side colors:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderTopColor="rgb(255,0,0)"
|
||||
borderBottomColor="ansi:green"
|
||||
borderLeftColor="#0000FF"
|
||||
borderRightColor="ansi256(200)"
|
||||
/>
|
||||
```
|
||||
|
||||
Border text (labels in the border):
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
borderStyle="round"
|
||||
borderText={{ title: "My Panel", align: "left" }}
|
||||
/>
|
||||
```
|
||||
|
||||
#### Background
|
||||
|
||||
```tsx
|
||||
<Box backgroundColor="rgb(40,40,40)">...</Box>
|
||||
```
|
||||
|
||||
#### Overflow
|
||||
|
||||
```tsx
|
||||
<Box overflow="visible">...</Box> // Content expands container (default)
|
||||
<Box overflow="hidden">...</Box> // Clip without scrolling
|
||||
<Box overflow="scroll">...</Box> // Enable scrolling (use ScrollBox)
|
||||
```
|
||||
|
||||
`overflowX` and `overflowY` control each axis independently.
|
||||
|
||||
#### Opaque
|
||||
|
||||
```tsx
|
||||
<Box opaque={true}>...</Box>
|
||||
```
|
||||
|
||||
Fills the box interior with spaces (using terminal's default background) before rendering children. Useful for absolute-positioned overlays where gaps would otherwise be transparent.
|
||||
|
||||
#### NoSelect
|
||||
|
||||
```tsx
|
||||
<Box noSelect={true}>...</Box> // Exclude from text selection
|
||||
<Box noSelect="from-left-edge">...</Box> // Exclude from column 0 to box edge
|
||||
```
|
||||
|
||||
Only affects alt-screen text selection. Useful for gutters (line numbers, diff markers).
|
||||
|
||||
## Spacer
|
||||
|
||||
`Spacer` fills all available space along the main axis (equivalent to `flexGrow: 1`).
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row">
|
||||
<Text>Left</Text>
|
||||
<Spacer />
|
||||
<Text>Right</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## Newline
|
||||
|
||||
Inserts line breaks.
|
||||
|
||||
```tsx
|
||||
<Text>
|
||||
Line 1
|
||||
<Newline />
|
||||
Line 2
|
||||
<Newline count={2} />
|
||||
Line 4 (after double break)
|
||||
</Text>
|
||||
```
|
||||
|
||||
## Layout Examples
|
||||
|
||||
### Two-column layout
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="row" width={80}>
|
||||
<Box width="50%" padding={1}>
|
||||
<Text>Left column</Text>
|
||||
</Box>
|
||||
<Box width="50%" padding={1}>
|
||||
<Text>Right column</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Centered content
|
||||
|
||||
```tsx
|
||||
<Box justifyContent="center" alignItems="center" height={20}>
|
||||
<Text>Centered!</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Sticky footer
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="column" height={24}>
|
||||
<Box flexGrow={1}>
|
||||
<Text>Scrollable content area</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>Status bar at bottom</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Bordered panel with title
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="round"
|
||||
borderColor="rgb(87,105,247)"
|
||||
padding={1}
|
||||
width={60}
|
||||
>
|
||||
<Text bold>Panel Title</Text>
|
||||
<Text>Panel content goes here.</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## NoSelect
|
||||
|
||||
Wraps a region to exclude it from text selection in alt-screen mode. A convenience wrapper around `Box` with `noSelect` set.
|
||||
|
||||
```tsx
|
||||
import { NoSelect } from '@anthropic/ink'
|
||||
|
||||
<Box flexDirection="row">
|
||||
<NoSelect>
|
||||
<Text dimColor>1 │ </Text>
|
||||
</NoSelect>
|
||||
<Text>selectable code here</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `fromLeftEdge` | `boolean` | `false` | Extend exclusion from column 0 to box's right edge |
|
||||
|
||||
Accepts all `BoxProps` except `noSelect`.
|
||||
|
||||
## BaseBox vs ThemedBox
|
||||
|
||||
Two versions of Box are exported:
|
||||
|
||||
- **`BaseBox`** (imported as `BaseBox`) -- Raw box, color props accept only raw `Color` values
|
||||
- **`Box`** (themed, imported as `Box`) -- Theme-aware, color props accept `keyof Theme | Color`
|
||||
|
||||
```tsx
|
||||
// Raw
|
||||
<BaseBox borderStyle="single" borderColor="rgb(255,0,0)" />
|
||||
|
||||
// Theme-aware (resolves 'permission' to the current theme's blue)
|
||||
<Box borderStyle="single" borderColor="permission" />
|
||||
```
|
||||
238
packages/@ant/ink/docs/03-text-and-styling.md
Normal file
238
packages/@ant/ink/docs/03-text-and-styling.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Chapter 3: Text & Styling
|
||||
|
||||
## Text Component
|
||||
|
||||
`Text` renders styled text content. It supports colors, emphasis, and text wrapping.
|
||||
|
||||
```tsx
|
||||
import { Text } from '@anthropic/ink'
|
||||
|
||||
<Text bold color="success">Operation complete</Text>
|
||||
```
|
||||
|
||||
### Text Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `color` | `keyof Theme \| Color` | - | Foreground color |
|
||||
| `backgroundColor` | `keyof Theme` | - | Background color (theme-aware) |
|
||||
| `bold` | `boolean` | `false` | Bold text |
|
||||
| `dimColor` | `boolean` | `false` | Dim text (uses theme's `inactive` color) |
|
||||
| `italic` | `boolean` | `false` | Italic text |
|
||||
| `underline` | `boolean` | `false` | Underlined text |
|
||||
| `strikethrough` | `boolean` | `false` | Strikethrough text |
|
||||
| `inverse` | `boolean` | `false` | Swap foreground/background |
|
||||
| `wrap` | `TextWrap` | `'wrap'` | Wrapping/truncation mode |
|
||||
| `children` | `ReactNode` | - | Text content |
|
||||
|
||||
> **Note:** `bold` and `dimColor` are mutually exclusive (ANSI terminals cannot render both simultaneously).
|
||||
|
||||
### BaseText vs ThemedText
|
||||
|
||||
- **`BaseText`** -- Accepts raw `Color` values only
|
||||
- **`Text`** (default export) -- Theme-aware, accepts `keyof Theme | Color` for `color`, and `keyof Theme` for `backgroundColor`
|
||||
|
||||
```tsx
|
||||
// Raw color
|
||||
<BaseText color="rgb(255,0,0)">Red text</BaseText>
|
||||
|
||||
// Theme key (resolved to current theme palette)
|
||||
<Text color="error">Error message</Text>
|
||||
|
||||
// Mixed
|
||||
<Text color="#FF0000">Custom red</Text>
|
||||
```
|
||||
|
||||
### Text Wrap Modes
|
||||
|
||||
```tsx
|
||||
<Text wrap="wrap">...</Text> // Word-wrap at container width (default)
|
||||
<Text wrap="wrap-trim">...</Text> // Wrap + trim trailing whitespace
|
||||
<Text wrap="end">...</Text> // Truncate with "..." at end
|
||||
<Text wrap="truncate-end">...</Text> // Same as "end"
|
||||
<Text wrap="truncate">...</Text> // Truncate (no ellipsis)
|
||||
<Text wrap="middle">...</Text> // "start...end"
|
||||
<Text wrap="truncate-middle">...</Text> // Same as "middle"
|
||||
<Text wrap="truncate-start">...</Text> // "...text"
|
||||
```
|
||||
|
||||
### TextHoverColorContext
|
||||
|
||||
Uncolored `Text` children inherit a hover color from context:
|
||||
|
||||
```tsx
|
||||
import { TextHoverColorContext } from '@anthropic/ink'
|
||||
|
||||
<TextHoverColorContext.Provider value="suggestion">
|
||||
<Text>Uncolored text gets the suggestion color</Text>
|
||||
<Text color="error">This stays red</Text>
|
||||
</TextHoverColorContext.Provider>
|
||||
```
|
||||
|
||||
Precedence: explicit `color` > `TextHoverColorContext` > `dimColor`.
|
||||
|
||||
## Color System
|
||||
|
||||
### Raw Color Formats
|
||||
|
||||
Four formats are supported for raw color values:
|
||||
|
||||
```tsx
|
||||
// RGB
|
||||
<Text color="rgb(255,107,128)">Bright red</Text>
|
||||
|
||||
// Hex
|
||||
<Text color="#FF6B80">Bright red</Text>
|
||||
|
||||
// ANSI 256-color
|
||||
<Text color="ansi256(196)">Red from 256-color palette</Text>
|
||||
|
||||
// Named ANSI 16-color
|
||||
<Text color="ansi:red">Red</Text>
|
||||
<Text color="ansi:greenBright">Bright green</Text>
|
||||
```
|
||||
|
||||
### ANSI Named Colors
|
||||
|
||||
Full list of `ansi:` prefixed names:
|
||||
|
||||
| Name | Color |
|
||||
|------|-------|
|
||||
| `ansi:black` | Black |
|
||||
| `ansi:red` | Red |
|
||||
| `ansi:green` | Green |
|
||||
| `ansi:yellow` | Yellow |
|
||||
| `ansi:blue` | Blue |
|
||||
| `ansi:magenta` | Magenta |
|
||||
| `ansi:cyan` | Cyan |
|
||||
| `ansi:white` | White |
|
||||
| `ansi:blackBright` | Dark gray |
|
||||
| `ansi:redBright` | Bright red |
|
||||
| `ansi:greenBright` | Bright green |
|
||||
| `ansi:yellowBright` | Bright yellow |
|
||||
| `ansi:blueBright` | Bright blue |
|
||||
| `ansi:magentaBright` | Bright magenta |
|
||||
| `ansi:cyanBright` | Bright cyan |
|
||||
| `ansi:whiteBright` | Bright white |
|
||||
|
||||
## Utility Functions
|
||||
|
||||
### `color(colorValue, themeName, type?)`
|
||||
|
||||
Curried theme-aware color function. Resolves theme keys to raw color values.
|
||||
|
||||
```tsx
|
||||
import { color } from '@anthropic/ink'
|
||||
|
||||
const paint = color('error', 'dark') // Returns (text: string) => string
|
||||
console.log(paint('failed')) // 'failed' wrapped in ANSI red codes
|
||||
|
||||
const paintFg = color('rgb(255,0,0)', 'dark', 'foreground')
|
||||
const paintBg = color('success', 'dark', 'background')
|
||||
```
|
||||
|
||||
Parameters:
|
||||
- `c` -- `keyof Theme | Color | undefined` -- Theme key or raw color
|
||||
- `theme` -- `ThemeName` -- Current theme
|
||||
- `type` -- `'foreground' | 'background'` (default `'foreground'`)
|
||||
|
||||
### `stringWidth(text)`
|
||||
|
||||
Measures the visual width of a string in terminal columns, accounting for:
|
||||
- CJK characters (2 columns each)
|
||||
- Emoji (2 columns each)
|
||||
- ANSI escape sequences (0 columns)
|
||||
|
||||
```tsx
|
||||
import { stringWidth } from '@anthropic/ink'
|
||||
|
||||
stringWidth('hello') // 5
|
||||
stringWidth('你好') // 4
|
||||
stringWidth('\x1b[31mhi') // 2 (ANSI codes ignored)
|
||||
```
|
||||
|
||||
### `wrapText(text, width, textWrap)`
|
||||
|
||||
Wraps text to a given width with the specified wrapping mode.
|
||||
|
||||
```tsx
|
||||
import { wrapText } from '@anthropic/ink'
|
||||
|
||||
wrapText('Hello World', 5, 'wrap') // 'Hello\nWorld'
|
||||
wrapText('Hello World', 8, 'end') // 'Hello...'
|
||||
```
|
||||
|
||||
### `wrapAnsi(text, width)`
|
||||
|
||||
Wraps text containing ANSI escape codes while preserving styling.
|
||||
|
||||
```tsx
|
||||
import { wrapAnsi } from '@anthropic/ink'
|
||||
|
||||
wrapAnsi('\x1b[31mHello World\x1b[0m', 5)
|
||||
// Wraps at word boundaries, keeps color codes intact
|
||||
```
|
||||
|
||||
### `measureElement(node)`
|
||||
|
||||
Measures a rendered DOM element's dimensions.
|
||||
|
||||
```tsx
|
||||
import { measureElement } from '@anthropic/ink'
|
||||
|
||||
const { width, height } = measureElement(domElement)
|
||||
```
|
||||
|
||||
## Link Component
|
||||
|
||||
Renders an OSC 8 terminal hyperlink (clickable URL in supported terminals).
|
||||
|
||||
```tsx
|
||||
import { Link } from '@anthropic/ink'
|
||||
|
||||
<Link url="https://example.com">
|
||||
<Text underline color="suggestion">example.com</Text>
|
||||
</Link>
|
||||
```
|
||||
|
||||
Props:
|
||||
- `url` -- `string` (required) -- Target URL
|
||||
- `children` -- `ReactNode` -- Display content
|
||||
- `fallback` -- `ReactNode` -- Shown when hyperlinks are unsupported
|
||||
|
||||
## RawAnsi Component
|
||||
|
||||
Renders pre-formatted ANSI strings directly into the layout.
|
||||
|
||||
```tsx
|
||||
import { RawAnsi } from '@anthropic/ink'
|
||||
|
||||
<RawAnsi
|
||||
lines={['\x1b[31mRed line 1\x1b[0m', '\x1b[32mGreen line 2\x1b[0m']}
|
||||
width={40}
|
||||
/>
|
||||
```
|
||||
|
||||
Props:
|
||||
- `lines` -- `string[]` -- Pre-rendered ANSI lines (one terminal row each)
|
||||
- `width` -- `number` -- Column width the producer wrapped to
|
||||
|
||||
## Border Rendering
|
||||
|
||||
### `renderBorder(box, output, options?)`
|
||||
|
||||
Low-level border rendering function used internally by Box.
|
||||
|
||||
```tsx
|
||||
import { renderBorder } from '@anthropic/ink'
|
||||
import type { BorderTextOptions } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
Border styles available (from `cli-boxes`):
|
||||
- `single` -- Thin lines `─│┌┐└┘`
|
||||
- `double` -- Double lines `═║╔╗╚╝`
|
||||
- `round` -- Rounded corners `─│╭╮╰╯`
|
||||
- `bold` -- Bold lines `━┃┏┓┗┛`
|
||||
- `singleDouble` -- Single horizontal, double vertical
|
||||
- `doubleSingle` -- Double horizontal, single vertical
|
||||
- `classic` -- ASCII `─|++++`
|
||||
213
packages/@ant/ink/docs/04-theme-system.md
Normal file
213
packages/@ant/ink/docs/04-theme-system.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Chapter 4: Theme System
|
||||
|
||||
The theme system provides consistent, accessible color palettes across the application. It supports dark mode, light mode, ANSI-only terminals, and colorblind-accessible variants.
|
||||
|
||||
## ThemeProvider
|
||||
|
||||
Wraps the application to provide theme context.
|
||||
|
||||
```tsx
|
||||
import { ThemeProvider } from '@anthropic/ink'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider initialState="dark" onThemeSave={(setting) => saveConfig(setting)}>
|
||||
<MyComponent />
|
||||
</ThemeProvider>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `children` | `ReactNode` | Child components |
|
||||
| `initialState` | `ThemeSetting` | Initial theme (default: loads from config) |
|
||||
| `onThemeSave` | `(setting: ThemeSetting) => void` | Called when theme is saved |
|
||||
|
||||
### Theme Configuration Injection
|
||||
|
||||
Before mounting, inject config persistence callbacks:
|
||||
|
||||
```tsx
|
||||
import { setThemeConfigCallbacks } from '@anthropic/ink'
|
||||
|
||||
setThemeConfigCallbacks({
|
||||
loadTheme: () => configStore.get('theme', 'dark'),
|
||||
saveTheme: (setting) => configStore.set('theme', setting),
|
||||
})
|
||||
```
|
||||
|
||||
## Theme Settings
|
||||
|
||||
```ts
|
||||
type ThemeSetting = 'auto' | 'dark' | 'light' | 'light-daltonized' | 'dark-daltonized' | 'light-ansi' | 'dark-ansi'
|
||||
type ThemeName = 'dark' | 'light' | 'light-daltonized' | 'dark-daltonized' | 'light-ansi' | 'dark-ansi'
|
||||
```
|
||||
|
||||
| Theme | Description |
|
||||
|-------|-------------|
|
||||
| `dark` | Dark theme with RGB colors (default) |
|
||||
| `light` | Light theme with RGB colors |
|
||||
| `dark-daltonized` | Colorblind-accessible dark theme |
|
||||
| `light-daltonized` | Colorblind-accessible light theme |
|
||||
| `dark-ansi` | Dark theme using only 16 ANSI colors |
|
||||
| `light-ansi` | Light theme using only 16 ANSI colors |
|
||||
| `auto` | Follows terminal's dark/light mode (resolved at runtime) |
|
||||
|
||||
## Theme Hooks
|
||||
|
||||
### `useTheme()`
|
||||
|
||||
Returns the resolved theme name and setter.
|
||||
|
||||
```tsx
|
||||
const [currentTheme, setTheme] = useTheme()
|
||||
// currentTheme: ThemeName (never 'auto')
|
||||
// setTheme: (setting: ThemeSetting) => void
|
||||
```
|
||||
|
||||
### `useThemeSetting()`
|
||||
|
||||
Returns the raw setting (may be `'auto'`).
|
||||
|
||||
```tsx
|
||||
const setting = useThemeSetting() // 'auto' | 'dark' | ...
|
||||
```
|
||||
|
||||
### `usePreviewTheme()`
|
||||
|
||||
Returns preview controls for a theme picker UI.
|
||||
|
||||
```tsx
|
||||
const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme()
|
||||
|
||||
// Show preview
|
||||
setPreviewTheme('light')
|
||||
|
||||
// User confirms
|
||||
savePreview()
|
||||
|
||||
// User cancels
|
||||
cancelPreview()
|
||||
```
|
||||
|
||||
## Theme Color Palette
|
||||
|
||||
Every theme defines these semantic color keys:
|
||||
|
||||
### Brand & Identity
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `claude` | Brand orange |
|
||||
| `claudeShimmer` | Lighter brand orange (animated) |
|
||||
| `permission` | Permission/blue |
|
||||
| `permissionShimmer` | Lighter permission blue |
|
||||
| `autoAccept` | Electric violet |
|
||||
| `planMode` | Teal/sage |
|
||||
| `ide` | Muted blue |
|
||||
|
||||
### Semantic Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `text` | Primary text color |
|
||||
| `inverseText` | Text on inverse backgrounds |
|
||||
| `inactive` | Dimmed/disabled elements |
|
||||
| `inactiveShimmer` | Lighter inactive |
|
||||
| `subtle` | Very subtle text |
|
||||
| `suggestion` | Interactive/accent |
|
||||
| `background` | General background accent |
|
||||
| `success` | Positive/success |
|
||||
| `error` | Negative/error |
|
||||
| `warning` | Caution/warning |
|
||||
| `warningShimmer` | Lighter warning |
|
||||
| `merged` | Merged state |
|
||||
|
||||
### Diff Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `diffAdded` | Added lines background |
|
||||
| `diffRemoved` | Removed lines background |
|
||||
| `diffAddedDimmed` | Dimmed added |
|
||||
| `diffRemovedDimmed` | Dimmed removed |
|
||||
| `diffAddedWord` | Word-level added |
|
||||
| `diffRemovedWord` | Word-level removed |
|
||||
|
||||
### UI Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `promptBorder` | Input prompt border |
|
||||
| `promptBorderShimmer` | Lighter prompt border |
|
||||
| `bashBorder` | Shell block border |
|
||||
| `selectionBg` | Text selection highlight background |
|
||||
| `userMessageBackground` | User message background |
|
||||
| `userMessageBackgroundHover` | User message hover |
|
||||
| `messageActionsBackground` | Action buttons background |
|
||||
|
||||
### Agent Colors
|
||||
|
||||
| Key | Purpose |
|
||||
|-----|---------|
|
||||
| `red_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `blue_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `green_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `yellow_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `purple_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `orange_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `pink_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
| `cyan_FOR_SUBAGENTS_ONLY` | Agent color assignment |
|
||||
|
||||
## Using Theme Colors in Components
|
||||
|
||||
### ThemedText
|
||||
|
||||
```tsx
|
||||
<Text color="success">Operation complete</Text>
|
||||
<Text color="error" bold>Failed!</Text>
|
||||
<Text color="claude">Claude says...</Text>
|
||||
<Text dimColor>Secondary info</Text>
|
||||
<Text backgroundColor="userMessageBackground">Highlighted</Text>
|
||||
```
|
||||
|
||||
### ThemedBox
|
||||
|
||||
```tsx
|
||||
<Box borderStyle="single" borderColor="permission" backgroundColor="userMessageBackground">
|
||||
<Text>Themed content</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### color() Utility
|
||||
|
||||
```tsx
|
||||
import { color, useTheme } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
const [themeName] = useTheme()
|
||||
const paint = color('success', themeName)
|
||||
// paint('text') returns ANSI-colored string
|
||||
}
|
||||
```
|
||||
|
||||
## Daltonized Themes
|
||||
|
||||
The daltonized themes (`light-daltonized`, `dark-daltonized`) are designed for users with protanopia/deuteranopia:
|
||||
|
||||
- Green/red diffs replaced with blue/red
|
||||
- Status colors use blue instead of green
|
||||
- Warning colors adjusted for better distinction
|
||||
- All color pairs verified for sufficient contrast
|
||||
|
||||
## System Theme Detection
|
||||
|
||||
When `ThemeSetting` is `'auto'`:
|
||||
|
||||
1. Seeds from `$COLORFGBG` environment variable
|
||||
2. Queries terminal via OSC 11 for live background color
|
||||
3. Watches for changes (terminal theme switch) in real-time
|
||||
4. Resolves to `'dark'` or `'light'` based on detected brightness
|
||||
390
packages/@ant/ink/docs/05-design-system.md
Normal file
390
packages/@ant/ink/docs/05-design-system.md
Normal file
@@ -0,0 +1,390 @@
|
||||
# Chapter 5: Design System Components
|
||||
|
||||
Pre-built theme-aware UI components for common terminal interface patterns.
|
||||
|
||||
## Dialog
|
||||
|
||||
Modal dialog with border, title, and keyboard navigation.
|
||||
|
||||
```tsx
|
||||
import { Dialog } from '@anthropic/ink'
|
||||
|
||||
<Dialog
|
||||
title="Confirm Action"
|
||||
subtitle="This cannot be undone"
|
||||
onCancel={() => setShowDialog(false)}
|
||||
color="warning"
|
||||
>
|
||||
<Text>Are you sure you want to proceed?</Text>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `title` | `ReactNode` | - | Dialog title (required) |
|
||||
| `subtitle` | `ReactNode` | - | Optional subtitle |
|
||||
| `children` | `ReactNode` | - | Dialog body content |
|
||||
| `onCancel` | `() => void` | - | Called on Esc/n (required) |
|
||||
| `color` | `keyof Theme` | `'permission'` | Title and border color |
|
||||
| `hideInputGuide` | `boolean` | `false` | Hide the keyboard hint footer |
|
||||
| `hideBorder` | `boolean` | `false` | Render without Pane border |
|
||||
| `inputGuide` | `(exitState) => ReactNode` | - | Custom input guide footer |
|
||||
| `isCancelActive` | `boolean` | `true` | Enable/disable cancel keybindings |
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
- **Enter** -- Confirm (consumer handles this)
|
||||
- **Esc / n** -- Cancel (calls `onCancel`)
|
||||
- **Ctrl+C / Ctrl+D** -- Double-press to exit
|
||||
|
||||
### Custom Input Guide
|
||||
|
||||
```tsx
|
||||
<Dialog
|
||||
title="Save file?"
|
||||
onCancel={handleCancel}
|
||||
inputGuide={(exitState) => (
|
||||
exitState.pending
|
||||
? <Text>Press {exitState.keyName} again to exit</Text>
|
||||
: <Text>Press Enter to save, Esc to cancel</Text>
|
||||
)}
|
||||
>
|
||||
...
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
## Pane
|
||||
|
||||
Bordered container with themed top border.
|
||||
|
||||
```tsx
|
||||
import { Pane } from '@anthropic/ink'
|
||||
|
||||
<Pane color="permission">
|
||||
<Text>Content inside a bordered pane</Text>
|
||||
</Pane>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `color` | `keyof Theme` | `'permission'` | Top border color |
|
||||
|
||||
## ProgressBar
|
||||
|
||||
Visual progress indicator.
|
||||
|
||||
```tsx
|
||||
import { ProgressBar } from '@anthropic/ink'
|
||||
|
||||
<ProgressBar
|
||||
ratio={0.65}
|
||||
width={40}
|
||||
fillColor="rate_limit_fill"
|
||||
emptyColor="rate_limit_empty"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `ratio` | `number` | - | Progress 0..1 (required) |
|
||||
| `width` | `number` | - | Character width (required) |
|
||||
| `fillColor` | `keyof Theme` | - | Filled portion color |
|
||||
| `emptyColor` | `keyof Theme` | - | Empty portion color |
|
||||
|
||||
## Spinner
|
||||
|
||||
Animated loading spinner. No props.
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '@anthropic/ink'
|
||||
|
||||
<Box gap={1}>
|
||||
<Spinner />
|
||||
<Text>Loading...</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## LoadingState
|
||||
|
||||
Loading message with spinner and optional subtitle.
|
||||
|
||||
```tsx
|
||||
import { LoadingState } from '@anthropic/ink'
|
||||
|
||||
<LoadingState
|
||||
message="Installing dependencies"
|
||||
subtitle="This may take a moment"
|
||||
bold={true}
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `message` | `string` | - | Loading message (required) |
|
||||
| `bold` | `boolean` | `false` | Bold message |
|
||||
| `dimColor` | `boolean` | `false` | Dimmed message |
|
||||
| `subtitle` | `string` | - | Secondary text below |
|
||||
|
||||
## StatusIcon
|
||||
|
||||
Semantic status indicator with icon and color.
|
||||
|
||||
```tsx
|
||||
import { StatusIcon } from '@anthropic/ink'
|
||||
|
||||
<StatusIcon status="success" withSpace />
|
||||
<Text>Build complete</Text>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `status` | `'success' \| 'error' \| 'warning' \| 'info' \| 'pending' \| 'loading'` | - | Status type (required) |
|
||||
| `withSpace` | `boolean` | `false` | Add trailing space |
|
||||
|
||||
Status icons:
|
||||
- `success` -- Green checkmark
|
||||
- `error` -- Red cross
|
||||
- `warning` -- Yellow warning
|
||||
- `info` -- Blue info
|
||||
- `pending` -- Dimmed circle
|
||||
- `loading` -- Dimmed ellipsis
|
||||
|
||||
## FuzzyPicker
|
||||
|
||||
Full-featured fuzzy search selector with preview support.
|
||||
|
||||
```tsx
|
||||
import { FuzzyPicker } from '@anthropic/ink'
|
||||
|
||||
<FuzzyPicker
|
||||
title="Select a file"
|
||||
items={files}
|
||||
getKey={(f) => f.path}
|
||||
renderItem={(f, focused) => <Text>{f.name}</Text>}
|
||||
onQueryChange={(q) => setFilteredFiles(filterFiles(q))}
|
||||
onSelect={(f) => openFile(f)}
|
||||
onCancel={() => setShowPicker(false)}
|
||||
/>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | `string` | Picker title (required) |
|
||||
| `items` | `readonly T[]` | Items to display (required) |
|
||||
| `getKey` | `(item: T) => string` | Unique key extractor (required) |
|
||||
| `renderItem` | `(item: T, isFocused: boolean) => ReactNode` | Item renderer (required) |
|
||||
| `onQueryChange` | `(query: string) => void` | Filter callback (required) |
|
||||
| `onSelect` | `(item: T) => void` | Enter key handler (required) |
|
||||
| `onCancel` | `() => void` | Esc handler (required) |
|
||||
| `renderPreview` | `(item: T) => ReactNode` | Preview panel renderer |
|
||||
| `previewPosition` | `'bottom' \| 'right'` | Preview placement |
|
||||
| `visibleCount` | `number` | Max visible items |
|
||||
| `direction` | `'down' \| 'up'` | Item ordering |
|
||||
| `onTab` | `PickerAction<T>` | Tab key handler |
|
||||
| `onShiftTab` | `PickerAction<T>` | Shift+Tab handler |
|
||||
| `onFocus` | `(item: T \| undefined) => void` | Focus change callback |
|
||||
| `emptyMessage` | `string \| ((query: string) => string)` | Empty state message |
|
||||
| `matchLabel` | `string` | Status line below list |
|
||||
| `placeholder` | `string` | Input placeholder |
|
||||
| `initialQuery` | `string` | Initial search query |
|
||||
| `selectAction` | `string` | Action label for byline |
|
||||
| `extraHints` | `ReactNode` | Additional keyboard hints |
|
||||
|
||||
## Tabs / Tab
|
||||
|
||||
Tabbed interface with keyboard navigation.
|
||||
|
||||
```tsx
|
||||
import { Tabs, Tab } from '@anthropic/ink'
|
||||
|
||||
<Tabs title="Settings" color="claude">
|
||||
<Tab title="General" id="general">
|
||||
<GeneralSettings />
|
||||
</Tab>
|
||||
<Tab title="Advanced" id="advanced">
|
||||
<AdvancedSettings />
|
||||
</Tab>
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
### Tabs Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactElement<TabProps>[]` | - | Tab elements |
|
||||
| `title` | `string` | - | Header title |
|
||||
| `color` | `keyof Theme` | - | Active tab indicator color |
|
||||
| `defaultTab` | `string` | - | Initial tab id |
|
||||
| `selectedTab` | `string` | - | Controlled selected tab |
|
||||
| `onTabChange` | `(tabId: string) => void` | - | Tab change callback |
|
||||
| `hidden` | `boolean` | `false` | Hide tab headers |
|
||||
| `useFullWidth` | `boolean` | `false` | Use full terminal width |
|
||||
| `banner` | `ReactNode` | - | Banner below tab headers |
|
||||
| `disableNavigation` | `boolean` | `false` | Disable keyboard nav |
|
||||
| `initialHeaderFocused` | `boolean` | `true` | Start with header focused |
|
||||
| `contentHeight` | `number` | - | Fixed content height |
|
||||
| `navFromContent` | `boolean` | `false` | Allow Tab/Arrow from content |
|
||||
|
||||
### Tab Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `title` | `string` | Tab label (required) |
|
||||
| `id` | `string` | Tab identifier |
|
||||
| `children` | `ReactNode` | Tab content |
|
||||
|
||||
### Tab Hooks
|
||||
|
||||
```tsx
|
||||
import { useTabsWidth, useTabHeaderFocus } from '@anthropic/ink'
|
||||
|
||||
const width = useTabsWidth() // Available content width
|
||||
const focused = useTabHeaderFocus() // Whether tab header is focused
|
||||
```
|
||||
|
||||
## ListItem
|
||||
|
||||
Selectable list item with focus/selection indicators.
|
||||
|
||||
```tsx
|
||||
import { ListItem } from '@anthropic/ink'
|
||||
|
||||
<ListItem isFocused={index === focusedIndex} isSelected={item.checked}>
|
||||
<Text>{item.label}</Text>
|
||||
</ListItem>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `isFocused` | `boolean` | - | Keyboard focus (required) |
|
||||
| `isSelected` | `boolean` | `false` | Checked/active state |
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `description` | `string` | - | Secondary text below |
|
||||
| `styled` | `boolean` | `true` | Auto-style based on state |
|
||||
| `disabled` | `boolean` | `false` | Dimmed, non-interactive |
|
||||
| `showScrollDown` | `boolean` | `false` | Scroll-down hint arrow |
|
||||
| `showScrollUp` | `boolean` | `false` | Scroll-up hint arrow |
|
||||
| `declareCursor` | `boolean` | `true` | Declare terminal cursor |
|
||||
|
||||
## SearchBox
|
||||
|
||||
Search input with theme-aware styling.
|
||||
|
||||
```tsx
|
||||
import { SearchBox } from '@anthropic/ink'
|
||||
|
||||
<SearchBox
|
||||
query={searchQuery}
|
||||
placeholder="Search..."
|
||||
isFocused={true}
|
||||
isTerminalFocused={true}
|
||||
width="100%"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `query` | `string` | - | Current search text |
|
||||
| `placeholder` | `string` | - | Placeholder text |
|
||||
| `isFocused` | `boolean` | - | Focus state |
|
||||
| `isTerminalFocused` | `boolean` | - | Terminal focus state |
|
||||
| `prefix` | `string` | - | Input prefix label |
|
||||
| `width` | `number \| string` | - | Input width |
|
||||
| `cursorOffset` | `number` | - | Cursor position offset |
|
||||
| `borderless` | `boolean` | `false` | Remove border |
|
||||
|
||||
## Divider
|
||||
|
||||
Horizontal/vertical divider line.
|
||||
|
||||
```tsx
|
||||
import { Divider } from '@anthropic/ink'
|
||||
|
||||
<Divider width={60} color="subtle" />
|
||||
<Divider title="Section Title" />
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `width` | `number` | Terminal width | Divider width |
|
||||
| `color` | `keyof Theme` | Dimmed | Line color |
|
||||
| `char` | `string` | `'─'` | Line character |
|
||||
| `padding` | `number` | `0` | Width reduction |
|
||||
| `title` | `string` | - | Centered title text |
|
||||
|
||||
## Byline
|
||||
|
||||
Footer with middot-separated items.
|
||||
|
||||
```tsx
|
||||
import { Byline } from '@anthropic/ink'
|
||||
|
||||
<Byline>
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
|
||||
</Byline>
|
||||
```
|
||||
|
||||
## KeyboardShortcutHint
|
||||
|
||||
Display a keyboard shortcut with its action.
|
||||
|
||||
```tsx
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
|
||||
<KeyboardShortcutHint shortcut="Enter" action="confirm" />
|
||||
<KeyboardShortcutHint shortcut="↑/↓" action="navigate" parens />
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `shortcut` | `string` | - | Key or chord to display |
|
||||
| `action` | `string` | - | Action description |
|
||||
| `parens` | `boolean` | `false` | Wrap in parentheses |
|
||||
| `bold` | `boolean` | `false` | Bold shortcut text |
|
||||
|
||||
## ConfigurableShortcutHint
|
||||
|
||||
Displays a shortcut hint that reads the actual keybinding from config.
|
||||
|
||||
```tsx
|
||||
import { ConfigurableShortcutHint } from '@anthropic/ink'
|
||||
|
||||
<ConfigurableShortcutHint
|
||||
action="confirm:no"
|
||||
context="Confirmation"
|
||||
fallback="Esc"
|
||||
description="cancel"
|
||||
/>
|
||||
```
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `action` | `string` | Keybinding action name |
|
||||
| `context` | `string` | Keybinding context |
|
||||
| `fallback` | `string` | Default shortcut if unbound |
|
||||
| `description` | `string` | Action description |
|
||||
| `parens` | `boolean` | Wrap in parentheses |
|
||||
| `bold` | `boolean` | Bold shortcut text |
|
||||
|
||||
## Ratchet
|
||||
|
||||
Animated counter component that prevents layout jumps.
|
||||
|
||||
```tsx
|
||||
import { Ratchet } from '@anthropic/ink'
|
||||
|
||||
<Ratchet lock="always">
|
||||
<Text>{count}</Text>
|
||||
</Ratchet>
|
||||
```
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `lock` | `'always' \| 'offscreen'` | `'always'` | Width locking strategy. `'always'` locks always; `'offscreen'` only locks when the element is scrolled off-screen |
|
||||
189
packages/@ant/ink/docs/06-scrolling.md
Normal file
189
packages/@ant/ink/docs/06-scrolling.md
Normal file
@@ -0,0 +1,189 @@
|
||||
# Chapter 6: Scrolling
|
||||
|
||||
## ScrollBox
|
||||
|
||||
A scrollable container with imperative scroll API, viewport culling, and sticky scroll support.
|
||||
|
||||
```tsx
|
||||
import { ScrollBox } from '@anthropic/ink'
|
||||
import type { ScrollBoxHandle } from '@anthropic/ink'
|
||||
|
||||
function MessageList({ messages }) {
|
||||
const scrollRef = useRef<ScrollBoxHandle>(null)
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollToBottom()
|
||||
}, [messages.length])
|
||||
|
||||
return (
|
||||
<ScrollBox ref={scrollRef} stickyScroll flexDirection="column" height={20}>
|
||||
{messages.map(msg => (
|
||||
<Text key={msg.id}>{msg.text}</Text>
|
||||
))}
|
||||
</ScrollBox>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
ScrollBox accepts all Box layout props except `textWrap`, `overflow`, `overflowX`, `overflowY` (these are managed internally):
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `ref` | `Ref<ScrollBoxHandle>` | - | Imperative handle |
|
||||
| `stickyScroll` | `boolean` | `false` | Auto-follow new content |
|
||||
| *(layout props)* | `Styles` | - | Width, height, padding, etc. |
|
||||
|
||||
### ScrollBoxHandle (Imperative API)
|
||||
|
||||
```ts
|
||||
interface ScrollBoxHandle {
|
||||
// Absolute positioning
|
||||
scrollTo(y: number): void
|
||||
scrollToElement(el: DOMElement, offset?: number): void
|
||||
scrollToBottom(): void
|
||||
|
||||
// Relative positioning
|
||||
scrollBy(dy: number): void
|
||||
|
||||
// Query state
|
||||
getScrollTop(): number
|
||||
getPendingDelta(): number
|
||||
getScrollHeight(): number
|
||||
getFreshScrollHeight(): number
|
||||
getViewportHeight(): number
|
||||
getViewportTop(): number
|
||||
isSticky(): boolean
|
||||
|
||||
// Events
|
||||
subscribe(listener: () => void): () => void
|
||||
|
||||
// Virtual scroll support
|
||||
setClampBounds(min?: number, max?: number): void
|
||||
}
|
||||
```
|
||||
|
||||
### Method Details
|
||||
|
||||
#### `scrollTo(y)`
|
||||
|
||||
Jump to an absolute position. Breaks sticky scroll.
|
||||
|
||||
```tsx
|
||||
scrollRef.current?.scrollTo(0) // Scroll to top
|
||||
```
|
||||
|
||||
#### `scrollBy(dy)`
|
||||
|
||||
Scroll by a relative amount. Accumulates deltas for smooth scrolling.
|
||||
|
||||
```tsx
|
||||
scrollRef.current?.scrollBy(3) // Scroll down 3 rows
|
||||
scrollRef.current?.scrollBy(-5) // Scroll up 5 rows
|
||||
```
|
||||
|
||||
#### `scrollToElement(el, offset?)`
|
||||
|
||||
Scroll so a specific DOM element is at the viewport top. More reliable than `scrollTo` because it reads the element's position at render time (avoids stale layout values).
|
||||
|
||||
```tsx
|
||||
const elementRef = useRef<DOMElement>(null)
|
||||
scrollRef.current?.scrollToElement(elementRef.current!, 2)
|
||||
```
|
||||
|
||||
#### `scrollToBottom()`
|
||||
|
||||
Pin scroll to bottom. Enables sticky mode.
|
||||
|
||||
```tsx
|
||||
scrollRef.current?.scrollToBottom()
|
||||
```
|
||||
|
||||
#### `isSticky()`
|
||||
|
||||
Returns `true` when scroll is pinned to the bottom.
|
||||
|
||||
```tsx
|
||||
if (scrollRef.current?.isSticky()) {
|
||||
// User hasn't scrolled up
|
||||
}
|
||||
```
|
||||
|
||||
#### `subscribe(listener)`
|
||||
|
||||
Subscribe to imperative scroll changes. Returns unsubscribe function.
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
return scrollRef.current?.subscribe(() => {
|
||||
console.log('Scroll position changed')
|
||||
})
|
||||
}, [])
|
||||
```
|
||||
|
||||
### Sticky Scroll
|
||||
|
||||
When `stickyScroll` is enabled:
|
||||
|
||||
1. Scroll automatically follows new content at the bottom
|
||||
2. User scroll (via `scrollBy`/`scrollTo`) breaks stickiness
|
||||
3. `scrollToBottom()` re-enables stickiness
|
||||
4. Content growth at the bottom is detected and followed automatically
|
||||
|
||||
```tsx
|
||||
<ScrollBox stickyScroll height={20}>
|
||||
{/* New items auto-scroll to bottom */}
|
||||
{items.map(renderItem)}
|
||||
</ScrollBox>
|
||||
```
|
||||
|
||||
### Viewport Culling
|
||||
|
||||
ScrollBox only renders children that intersect the visible viewport. Children outside the viewport are still mounted in React but skipped during terminal rendering. This makes large lists performant.
|
||||
|
||||
### Virtual Scrolling
|
||||
|
||||
For very large lists, use `setClampBounds` in combination with a virtual scrolling hook:
|
||||
|
||||
```tsx
|
||||
const scrollRef = useRef<ScrollBoxHandle>(null)
|
||||
|
||||
// After computing visible range
|
||||
scrollRef.current?.setClampBounds(firstVisibleRow, lastVisibleRow)
|
||||
```
|
||||
|
||||
This prevents burst `scrollTo` calls from showing blank space beyond mounted content.
|
||||
|
||||
### Scroll Events
|
||||
|
||||
ScrollBox bypasses React state for scroll operations. Instead:
|
||||
1. `scrollTo`/`scrollBy` mutate `scrollTop` directly on the DOM node
|
||||
2. The node is marked dirty
|
||||
3. A microtask-deferred render fires to coalesce multiple scroll events
|
||||
4. The Ink renderer reads `scrollTop` during layout
|
||||
|
||||
This avoids React reconciler overhead per wheel event.
|
||||
|
||||
### Integration with Mouse Wheel
|
||||
|
||||
In alt-screen mode, mouse wheel events are captured by the `App` component and forwarded to the focused ScrollBox:
|
||||
|
||||
```
|
||||
Wheel event → App.handleMouseEvent → ScrollBox.scrollBy(delta)
|
||||
```
|
||||
|
||||
### Layout Structure
|
||||
|
||||
ScrollBox creates a two-level DOM structure:
|
||||
|
||||
```
|
||||
ink-box (overflow: scroll, constrained height)
|
||||
└── Box (flexGrow: 1, flexShrink: 0, width: 100%)
|
||||
├── Child 1
|
||||
├── Child 2
|
||||
└── ...
|
||||
```
|
||||
|
||||
The outer `ink-box` is the viewport with constrained size. The inner `Box` grows to fit all content. The renderer computes `scrollHeight` from the inner box and translates content by `-scrollTop`.
|
||||
267
packages/@ant/ink/docs/07-user-input.md
Normal file
267
packages/@ant/ink/docs/07-user-input.md
Normal file
@@ -0,0 +1,267 @@
|
||||
# Chapter 7: User Input
|
||||
|
||||
## useInput
|
||||
|
||||
The primary hook for handling keyboard input.
|
||||
|
||||
```tsx
|
||||
import { useInput } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
useInput((input, key, event) => {
|
||||
if (input === 'q') {
|
||||
// 'q' key pressed
|
||||
}
|
||||
if (key.leftArrow) {
|
||||
// Left arrow
|
||||
}
|
||||
if (key.ctrl && input === 'c') {
|
||||
// Ctrl+C (only if exitOnCtrlC is false)
|
||||
}
|
||||
if (key.meta && input === 'b') {
|
||||
// Alt+B (Option+B on Mac)
|
||||
}
|
||||
if (key.shift && input === 'Tab') {
|
||||
// Shift+Tab
|
||||
}
|
||||
})
|
||||
|
||||
return <Text>Press keys...</Text>
|
||||
}
|
||||
```
|
||||
|
||||
### Signature
|
||||
|
||||
```ts
|
||||
function useInput(
|
||||
handler: (input: string, key: Key, event: InputEvent) => void,
|
||||
options?: { isActive?: boolean }
|
||||
): void
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
- **`input`** (`string`) -- The character entered. Empty string for non-printable keys (arrows, function keys). For paste events, the entire pasted text.
|
||||
- **`key`** (`Key`) -- Parsed key metadata (see below)
|
||||
- **`event`** (`InputEvent`) -- Raw event with `stopImmediatePropagation()`
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `isActive` | `boolean` | `true` | Enable/disable input handling |
|
||||
|
||||
### Key Object
|
||||
|
||||
```ts
|
||||
type Key = {
|
||||
upArrow: boolean
|
||||
downArrow: boolean
|
||||
leftArrow: boolean
|
||||
rightArrow: boolean
|
||||
pageDown: boolean
|
||||
pageUp: boolean
|
||||
wheelUp: boolean // Mouse wheel in alt-screen
|
||||
wheelDown: boolean // Mouse wheel in alt-screen
|
||||
home: boolean
|
||||
end: boolean
|
||||
return: boolean
|
||||
escape: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
fn: boolean
|
||||
tab: boolean
|
||||
backspace: boolean
|
||||
delete: boolean
|
||||
meta: boolean // Alt / Option
|
||||
super: boolean // Cmd (macOS) / Win key
|
||||
}
|
||||
```
|
||||
|
||||
### Event Propagation
|
||||
|
||||
Multiple `useInput` handlers form a chain. Call `event.stopImmediatePropagation()` to prevent downstream handlers from receiving the event:
|
||||
|
||||
```tsx
|
||||
useInput((input, key, event) => {
|
||||
if (input === 'j') {
|
||||
// Consumed by this handler
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
// Other handlers won't see 'j'
|
||||
})
|
||||
|
||||
useInput((input, key) => {
|
||||
// This won't fire for 'j'
|
||||
})
|
||||
```
|
||||
|
||||
### Raw Mode
|
||||
|
||||
`useInput` automatically enables raw mode on stdin when active. Raw mode is reference-counted -- it stays enabled as long as any hook has `isActive: true`.
|
||||
|
||||
In raw mode:
|
||||
- Keystrokes don't echo
|
||||
- Ctrl+C is not sent as signal (app must handle it)
|
||||
- Line buffering is disabled
|
||||
|
||||
## InputEvent
|
||||
|
||||
```ts
|
||||
class InputEvent extends Event {
|
||||
readonly input: string
|
||||
readonly key: Key
|
||||
readonly keypress: ParsedKey // Raw parsed keypress data
|
||||
}
|
||||
```
|
||||
|
||||
## KeyboardEvent
|
||||
|
||||
DOM-like keyboard event dispatched to focused elements:
|
||||
|
||||
```ts
|
||||
class KeyboardEvent extends Event {
|
||||
readonly key: Key
|
||||
}
|
||||
```
|
||||
|
||||
Used with `Box`'s `onKeyDown` and `onKeyDownCapture` props:
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
onKeyDown={(event) => {
|
||||
if (event.key.return) {
|
||||
handleSubmit()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>Press Enter to submit</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## Key Parsing
|
||||
|
||||
Ink supports multiple keyboard protocols:
|
||||
|
||||
### Standard Escape Sequences
|
||||
- Arrow keys, function keys, Home/End, Page Up/Down
|
||||
- Ctrl+letter combinations
|
||||
- Shift, Alt, Meta modifiers
|
||||
|
||||
### Kitty Keyboard Protocol (CSI u)
|
||||
Extended key reporting with full modifier support:
|
||||
- Distinguishes Ctrl+Shift+A from Ctrl+A
|
||||
- Reports Super (Cmd/Win) key
|
||||
- Sends key release events
|
||||
|
||||
### xterm modifyOtherKeys
|
||||
Alternative extended key reporting for xterm-compatible terminals.
|
||||
|
||||
### Application Keypad Mode
|
||||
Numpad keys mapped to their digit characters.
|
||||
|
||||
## Paste Detection
|
||||
|
||||
When `Bracketed Paste` mode is enabled (DECSET 2004), pasted text is delivered as a single `InputEvent` with the full text in `input`. This distinguishes paste from rapid typing:
|
||||
|
||||
```tsx
|
||||
useInput((input, key, event) => {
|
||||
if (event.keypress.paste) {
|
||||
// User pasted text -- handle as a batch
|
||||
handlePaste(input)
|
||||
} else {
|
||||
// Regular keypress
|
||||
handleKey(input, key)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
## Mouse Events (Alt-Screen Only)
|
||||
|
||||
In alternate screen mode, mouse events are parsed and dispatched:
|
||||
|
||||
### Click Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onClick={(event) => {
|
||||
console.log(`Clicked at (${event.x}, ${event.y})`)
|
||||
event.stopImmediatePropagation()
|
||||
}}
|
||||
>
|
||||
<Text>Click me</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Hover Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<Text>{hovered ? 'Hovered!' : 'Hover me'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
Hover events use `mouseenter`/`mouseleave` semantics (no bubbling between children).
|
||||
|
||||
### Wheel Events
|
||||
|
||||
Mouse wheel events arrive as `Key.wheelUp`/`Key.wheelDown`:
|
||||
|
||||
```tsx
|
||||
useInput((input, key) => {
|
||||
if (key.wheelUp) scrollUp()
|
||||
if (key.wheelDown) scrollDown()
|
||||
})
|
||||
```
|
||||
|
||||
## useStdin
|
||||
|
||||
Lower-level access to the stdin stream.
|
||||
|
||||
```tsx
|
||||
import { useStdin } from '@anthropic/ink'
|
||||
|
||||
const {
|
||||
stdin, // Raw stdin stream
|
||||
setRawMode, // (enabled: boolean) => void
|
||||
isRawModeSupported, // boolean
|
||||
internal_exitOnCtrlC, // boolean
|
||||
internal_eventEmitter, // EventEmitter | undefined
|
||||
internal_querier, // Terminal querier
|
||||
} = useStdin()
|
||||
```
|
||||
|
||||
> **Prefer `useInput` for keyboard handling.** `useStdin` is for advanced use cases like terminal querying or custom event handling.
|
||||
|
||||
## Button Component
|
||||
|
||||
Interactive button that responds to keyboard and mouse:
|
||||
|
||||
```tsx
|
||||
import { Button } from '@anthropic/ink'
|
||||
|
||||
<Button onAction={() => handleClick()} tabIndex={0} autoFocus>
|
||||
{(state) => (
|
||||
<Text bold={state.focused} color={state.focused ? 'claude' : 'text'}>
|
||||
{state.focused ? '> Click Me' : ' Click Me'}
|
||||
</Text>
|
||||
)}
|
||||
</Button>
|
||||
```
|
||||
|
||||
Button receives a render prop with state:
|
||||
|
||||
```ts
|
||||
type ButtonState = {
|
||||
focused: boolean // Has keyboard focus
|
||||
hovered: boolean // Mouse is over it (alt-screen)
|
||||
active: boolean // True for 100ms after activation (flash effect)
|
||||
}
|
||||
```
|
||||
|
||||
Activation triggers: Enter key, Space key, or mouse click.
|
||||
302
packages/@ant/ink/docs/08-keybindings.md
Normal file
302
packages/@ant/ink/docs/08-keybindings.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# Chapter 8: Keybinding System
|
||||
|
||||
The keybinding system provides configurable, context-aware keyboard shortcuts with chord sequence support.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
KeybindingSetup (loads config)
|
||||
└── KeybindingProvider (provides context)
|
||||
├── useKeybinding(action, handler)
|
||||
├── useKeybindings({ action: handler })
|
||||
├── useKeybindingContext()
|
||||
└── useRegisterKeybindingContext(name, isActive)
|
||||
```
|
||||
|
||||
## KeybindingSetup
|
||||
|
||||
Loads and validates keybinding configuration at app startup.
|
||||
|
||||
```tsx
|
||||
import { KeybindingSetup } from '@anthropic/ink'
|
||||
|
||||
<KeybindingSetup
|
||||
loadBindings={() => parseUserKeybindings(configFile)}
|
||||
subscribeToChanges={(cb) => watchConfigFile(cb)}
|
||||
onWarnings={(warnings, isReload) => {
|
||||
warnings.forEach(w => console.warn(w.message))
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</KeybindingSetup>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Description |
|
||||
|------|------|-------------|
|
||||
| `children` | `ReactNode` | App tree |
|
||||
| `loadBindings` | `() => KeybindingsLoadResult` | Load bindings from config |
|
||||
| `subscribeToChanges` | `(cb) => unsubscribe` | Watch for config changes |
|
||||
| `initWatcher` | `() => void \| Promise<void>` | One-time setup (optional) |
|
||||
| `onWarnings` | `(warnings, isReload) => void` | Validation warnings (optional) |
|
||||
| `onDebugLog` | `(message) => void` | Debug logging (optional) |
|
||||
|
||||
### KeybindingsLoadResult
|
||||
|
||||
```ts
|
||||
type KeybindingsLoadResult = {
|
||||
bindings: ParsedBinding[]
|
||||
warnings: KeybindingWarning[]
|
||||
}
|
||||
```
|
||||
|
||||
### KeybindingWarning
|
||||
|
||||
```ts
|
||||
type KeybindingWarning = {
|
||||
type: 'parse_error' | 'duplicate' | 'reserved' | 'invalid_context' | 'invalid_action'
|
||||
severity: 'error' | 'warning'
|
||||
message: string
|
||||
key?: string
|
||||
context?: string
|
||||
action?: string
|
||||
suggestion?: string
|
||||
}
|
||||
```
|
||||
|
||||
## KeybindingProvider
|
||||
|
||||
Context provider that holds binding state and resolution logic. Automatically provided by `KeybindingSetup`.
|
||||
|
||||
## useKeybinding
|
||||
|
||||
Register a handler for a keybinding action.
|
||||
|
||||
```tsx
|
||||
import { useKeybinding } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
useKeybinding('app:toggleTodos', () => {
|
||||
setShowTodos(prev => !prev)
|
||||
}, { context: 'Global' })
|
||||
|
||||
// Return false to NOT consume the event (allow propagation)
|
||||
useKeybinding('scroll:lineDown', () => {
|
||||
if (!hasContent) return false // Don't consume
|
||||
scrollBy(1)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Signature
|
||||
|
||||
```ts
|
||||
function useKeybinding(
|
||||
action: string,
|
||||
handler: () => void | false | Promise<void>,
|
||||
options?: { context?: string; isActive?: boolean }
|
||||
): void
|
||||
```
|
||||
|
||||
### Handler Return Values
|
||||
|
||||
| Return | Effect |
|
||||
|--------|--------|
|
||||
| `undefined` / `void` | Event consumed, stop propagation |
|
||||
| `false` | Event NOT consumed, propagate to other handlers |
|
||||
| `Promise<void>` | Async handler, treated as consumed |
|
||||
|
||||
## useKeybindings
|
||||
|
||||
Register multiple handlers in one hook (reduces `useInput` overhead).
|
||||
|
||||
```tsx
|
||||
import { useKeybindings } from '@anthropic/ink'
|
||||
|
||||
useKeybindings({
|
||||
'chat:submit': () => handleSubmit(),
|
||||
'chat:cancel': () => handleCancel(),
|
||||
'scroll:pageDown': () => {
|
||||
scrollBy(viewportHeight)
|
||||
},
|
||||
'scroll:lineDown': () => {
|
||||
if (!hasContent) return false
|
||||
scrollBy(1)
|
||||
},
|
||||
}, { context: 'Chat' })
|
||||
```
|
||||
|
||||
## Keybinding Contexts
|
||||
|
||||
Contexts allow the same key to perform different actions depending on what's active.
|
||||
|
||||
```tsx
|
||||
// Register a context as active
|
||||
import { useRegisterKeybindingContext } from '@anthropic/ink'
|
||||
|
||||
function ThemePicker({ isOpen }) {
|
||||
useRegisterKeybindingContext('ThemePicker', isOpen)
|
||||
|
||||
// While open, 'ThemePicker' context bindings take precedence
|
||||
useKeybinding('picker:select', handleSelect, { context: 'ThemePicker' })
|
||||
|
||||
return isOpen ? <PickerUI /> : null
|
||||
}
|
||||
```
|
||||
|
||||
Context resolution order:
|
||||
1. Registered active contexts (most recent first)
|
||||
2. The hook's own `context` parameter
|
||||
3. `'Global'` (always checked last)
|
||||
|
||||
## Chord Sequences
|
||||
|
||||
Keybindings support multi-key sequences (chords):
|
||||
|
||||
```
|
||||
"ctrl+k ctrl+s" → Save (press Ctrl+K, then Ctrl+S)
|
||||
"ctrl+k ctrl+c" → Close (press Ctrl+K, then Ctrl+C)
|
||||
```
|
||||
|
||||
When a chord prefix is pressed:
|
||||
- `result.type === 'chord_started'` -- Show "Ctrl+K ..." pending indicator
|
||||
- Next key completes or cancels the chord
|
||||
- `result.type === 'chord_cancelled'` -- Invalid key, reset
|
||||
|
||||
## KeybindingContext Hook
|
||||
|
||||
```tsx
|
||||
import { useKeybindingContext, useOptionalKeybindingContext } from '@anthropic/ink'
|
||||
|
||||
const ctx = useKeybindingContext()
|
||||
// ctx.resolve(input, key, contexts) → ResolveResult
|
||||
// ctx.bindings → ParsedBinding[]
|
||||
// ctx.pendingChord → ParsedKeystroke[] | null
|
||||
// ctx.activeContexts → Set<string>
|
||||
// ctx.getDisplayText(action, context) → string | undefined
|
||||
// ctx.invokeAction(action) → boolean
|
||||
// ctx.registerHandler(registration) → () => void (unsubscribe)
|
||||
|
||||
// Returns null outside provider (no throw)
|
||||
const optionalCtx = useOptionalKeybindingContext()
|
||||
```
|
||||
|
||||
## Parser Functions
|
||||
|
||||
Parse and format keybinding strings:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
parseKeystroke,
|
||||
parseChord,
|
||||
keystrokeToString,
|
||||
chordToString,
|
||||
keystrokeToDisplayString,
|
||||
chordToDisplayString,
|
||||
parseBindings,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### `parseKeystroke(str)`
|
||||
|
||||
Parse a single keystroke string:
|
||||
|
||||
```ts
|
||||
parseKeystroke('ctrl+shift+enter')
|
||||
// → { key: 'enter', ctrl: true, alt: false, shift: true, meta: false, super: false }
|
||||
```
|
||||
|
||||
### `parseChord(str)`
|
||||
|
||||
Parse a chord (space-separated keystrokes):
|
||||
|
||||
```ts
|
||||
parseChord('ctrl+k ctrl+s')
|
||||
// → [{ key: 'k', ctrl: true, ... }, { key: 's', ctrl: true, ... }]
|
||||
```
|
||||
|
||||
### `keystrokeToString(ks)` / `chordToString(chord)`
|
||||
|
||||
Convert parsed keystroke/chord back to string.
|
||||
|
||||
### `keystrokeToDisplayString(ks)` / `chordToDisplayString(chord)`
|
||||
|
||||
Convert to human-readable display string (platform-aware).
|
||||
|
||||
### `parseBindings(blocks)`
|
||||
|
||||
Parse a keybinding configuration:
|
||||
|
||||
```ts
|
||||
parseBindings([
|
||||
{
|
||||
context: 'Global',
|
||||
bindings: {
|
||||
'ctrl+s': 'app:save',
|
||||
'ctrl+k ctrl+s': 'app:saveAs',
|
||||
}
|
||||
}
|
||||
])
|
||||
// → ParsedBinding[]
|
||||
```
|
||||
|
||||
## Match Functions
|
||||
|
||||
```tsx
|
||||
import { getKeyName, matchesKeystroke, matchesBinding } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### `getKeyName(input, key)`
|
||||
|
||||
Get the canonical key name from raw input:
|
||||
|
||||
```ts
|
||||
getKeyName('\x1b[A', { upArrow: true }) // 'up'
|
||||
```
|
||||
|
||||
### `matchesKeystroke(input, key, target)`
|
||||
|
||||
Check if raw input matches a parsed keystroke:
|
||||
|
||||
```ts
|
||||
matchesKeystroke('s', { ctrl: true, shift: false }, { key: 's', ctrl: true })
|
||||
```
|
||||
|
||||
### `matchesBinding(input, key, binding)`
|
||||
|
||||
Check if raw input matches any keystroke in a binding's chord.
|
||||
|
||||
## Resolver Functions
|
||||
|
||||
```tsx
|
||||
import { resolveKey, resolveKeyWithChordState, getBindingDisplayText } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### `resolveKey(input, key, contexts, bindings)`
|
||||
|
||||
Resolve input to a binding action:
|
||||
|
||||
```ts
|
||||
const result = resolveKey('s', { ctrl: true, shift: false }, ['Global'], bindings)
|
||||
// result.type: 'match' | 'none' | 'unbound'
|
||||
// result.action: string (when type === 'match')
|
||||
```
|
||||
|
||||
### `resolveKeyWithChordState(input, key, contexts, bindings, pendingChord)`
|
||||
|
||||
Resolve with chord state:
|
||||
|
||||
```ts
|
||||
const result = resolveKeyWithChordState('k', key, ['Global'], bindings, null)
|
||||
// result.type: 'match' | 'none' | 'unbound' | 'chord_started' | 'chord_cancelled'
|
||||
// result.pending: ParsedKeystroke[] (when type === 'chord_started')
|
||||
```
|
||||
|
||||
### `getBindingDisplayText(action, context, bindings)`
|
||||
|
||||
Get the display string for a binding:
|
||||
|
||||
```ts
|
||||
getBindingDisplayText('app:save', 'Global', bindings) // 'Ctrl+S'
|
||||
```
|
||||
407
packages/@ant/ink/docs/09-hooks-reference.md
Normal file
407
packages/@ant/ink/docs/09-hooks-reference.md
Normal file
@@ -0,0 +1,407 @@
|
||||
# Chapter 9: Hooks Reference
|
||||
|
||||
Complete API reference for all hooks exported by `@anthropic/ink`.
|
||||
|
||||
---
|
||||
|
||||
## Application Hooks
|
||||
|
||||
### `useApp()`
|
||||
|
||||
Access app-level operations.
|
||||
|
||||
```ts
|
||||
function useApp(): {
|
||||
exit: (error?: Error) => void
|
||||
}
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const { exit } = useApp()
|
||||
// Gracefully unmount and exit
|
||||
exit()
|
||||
```
|
||||
|
||||
### `useStdin()`
|
||||
|
||||
Access the stdin stream and raw mode control.
|
||||
|
||||
```ts
|
||||
function useStdin(): {
|
||||
stdin: NodeJS.ReadStream
|
||||
isRawModeSupported: boolean
|
||||
setRawMode: (enabled: boolean) => void
|
||||
internal_exitOnCtrlC: boolean
|
||||
internal_eventEmitter: EventEmitter | undefined
|
||||
internal_querier: TerminalQuerier | null
|
||||
}
|
||||
```
|
||||
|
||||
> Prefer `useInput` for keyboard handling.
|
||||
|
||||
---
|
||||
|
||||
## Input Hooks
|
||||
|
||||
### `useInput(handler, options?)`
|
||||
|
||||
Handle keyboard input. See [Chapter 7](./07-user-input.md) for full details.
|
||||
|
||||
```ts
|
||||
function useInput(
|
||||
handler: (input: string, key: Key, event: InputEvent) => void,
|
||||
options?: { isActive?: boolean }
|
||||
): void
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Terminal Hooks
|
||||
|
||||
### `useTerminalSize()`
|
||||
|
||||
Get current terminal dimensions.
|
||||
|
||||
```ts
|
||||
function useTerminalSize(): {
|
||||
columns: number
|
||||
rows: number
|
||||
}
|
||||
```
|
||||
|
||||
Throws if used outside `<App>`.
|
||||
|
||||
### `useTerminalFocus()`
|
||||
|
||||
Track whether the terminal window is focused.
|
||||
|
||||
```ts
|
||||
function useTerminalFocus(): boolean
|
||||
```
|
||||
|
||||
Uses DECSET 1004 focus reporting. Returns `true` when focused.
|
||||
|
||||
### `useTerminalTitle(title)`
|
||||
|
||||
Set the terminal window title.
|
||||
|
||||
```ts
|
||||
function useTerminalTitle(title: string | null): void
|
||||
```
|
||||
|
||||
Pass `null` to clear the title.
|
||||
|
||||
### `useTerminalViewport()`
|
||||
|
||||
Track element visibility in the terminal viewport.
|
||||
|
||||
```ts
|
||||
function useTerminalViewport(): [
|
||||
ref: (element: DOMElement | null) => void,
|
||||
entry: { isVisible: boolean }
|
||||
]
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const [viewportRef, { isVisible }] = useTerminalViewport()
|
||||
|
||||
<Box ref={viewportRef}>
|
||||
<Text>{isVisible ? 'Visible' : 'Scrolled off'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### `useTabStatus(kind)`
|
||||
|
||||
Set tab status indicator in terminal tab bar (OSC 21337).
|
||||
|
||||
```ts
|
||||
type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
function useTabStatus(kind: TabStatusKind | null): void
|
||||
```
|
||||
|
||||
### `useTerminalNotification()`
|
||||
|
||||
Send terminal notifications (iTerm2, Kitty, Ghostty, bell).
|
||||
|
||||
```ts
|
||||
function useTerminalNotification(): {
|
||||
notifyITerm2: (opts: { message: string; title?: string }) => void
|
||||
notifyKitty: (opts: { message: string; title: string; id: number }) => void
|
||||
notifyGhostty: (opts: { message: string; title: string }) => void
|
||||
notifyBell: () => void
|
||||
progress: (state: Progress['state'] | null, percentage?: number) => void
|
||||
}
|
||||
```
|
||||
|
||||
Requires `TerminalWriteProvider` in the tree.
|
||||
|
||||
Progress states: `'running'`, `'completed'`, `'error'`, `'indeterminate'`, `null` (clear).
|
||||
|
||||
---
|
||||
|
||||
## Animation & Timing Hooks
|
||||
|
||||
### `useInterval(callback, intervalMs)`
|
||||
|
||||
Clock-backed interval timer.
|
||||
|
||||
```ts
|
||||
function useInterval(callback: () => void, intervalMs: number | null): void
|
||||
```
|
||||
|
||||
Pass `null` to pause. Shares the application clock for efficient batching.
|
||||
|
||||
### `useAnimationTimer(intervalMs)`
|
||||
|
||||
Returns the current clock time, updating at the given interval.
|
||||
|
||||
```ts
|
||||
function useAnimationTimer(intervalMs: number): number
|
||||
```
|
||||
|
||||
Subscribes as non-keepAlive -- won't keep the clock running on its own.
|
||||
|
||||
### `useAnimationFrame(intervalMs?)`
|
||||
|
||||
Synchronized animation hook that pauses when offscreen.
|
||||
|
||||
```ts
|
||||
function useAnimationFrame(
|
||||
intervalMs?: number | null, // default 16
|
||||
): [ref: (element: DOMElement | null) => void, time: number]
|
||||
```
|
||||
|
||||
Returns a ref callback (attach to animated element) and the current animation time. All instances share the same clock. Pass `null` to pause.
|
||||
|
||||
```tsx
|
||||
const [ref, time] = useAnimationFrame(120)
|
||||
const frame = Math.floor(time / 120) % FRAMES.length
|
||||
return <Box ref={ref}>{FRAMES[frame]}</Box>
|
||||
```
|
||||
|
||||
### `useTimeout(delayMs, resetTrigger?)`
|
||||
|
||||
One-shot timer.
|
||||
|
||||
```ts
|
||||
function useTimeout(delay: number, resetTrigger?: number): boolean
|
||||
```
|
||||
|
||||
Returns `true` when the timeout has elapsed. Change `resetTrigger` to restart.
|
||||
|
||||
### `useMinDisplayTime(value, minMs)`
|
||||
|
||||
Ensure a value is displayed for at least `minMs` milliseconds.
|
||||
|
||||
```ts
|
||||
function useMinDisplayTime<T>(value: T, minMs: number): T
|
||||
```
|
||||
|
||||
Holds the previous value until `minMs` has elapsed, then switches to the new value.
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
// Keep showing "Loading" for at least 300ms to prevent flash
|
||||
const displayValue = useMinDisplayTime(isLoading ? 'loading' : 'done', 300)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Interaction Hooks
|
||||
|
||||
### `useDoublePress(setPending, onDoublePress, onFirstPress?)`
|
||||
|
||||
Detect double-press (double-click equivalent for keyboard).
|
||||
|
||||
```ts
|
||||
export const DOUBLE_PRESS_TIMEOUT_MS = 800
|
||||
|
||||
function useDoublePress(
|
||||
setPending: (pending: boolean) => void,
|
||||
onDoublePress: () => void,
|
||||
onFirstPress?: () => void
|
||||
): () => void // Returns the press handler
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const [pendingExit, setPendingExit] = useState(false)
|
||||
const handlePress = useDoublePress(
|
||||
setPendingExit,
|
||||
() => exit(), // Double press
|
||||
() => {}, // First press
|
||||
)
|
||||
|
||||
useInput((input, key) => {
|
||||
if (key.escape) handlePress()
|
||||
})
|
||||
```
|
||||
|
||||
### `useExitOnCtrlCD(options?)`
|
||||
|
||||
Handle Ctrl+C / Ctrl+D with double-press confirmation.
|
||||
|
||||
```ts
|
||||
type ExitState = {
|
||||
pending: boolean
|
||||
keyName: 'Ctrl-C' | 'Ctrl-D' | null
|
||||
}
|
||||
|
||||
function useExitOnCtrlCDWithKeybindings(
|
||||
onExit?: () => void,
|
||||
onInterrupt?: () => boolean,
|
||||
isActive?: boolean
|
||||
): ExitState
|
||||
```
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const exitState = useExitOnCtrlCDWithKeybindings(
|
||||
() => exit(),
|
||||
() => { /* return true to prevent exit */ }
|
||||
)
|
||||
|
||||
if (exitState.pending) {
|
||||
return <Text>Press {exitState.keyName} again to exit</Text>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Selection Hooks (Alt-Screen Only)
|
||||
|
||||
### `useSelection()`
|
||||
|
||||
Text selection operations.
|
||||
|
||||
```ts
|
||||
function useSelection(): {
|
||||
copySelection: () => string
|
||||
copySelectionNoClear: () => string
|
||||
clearSelection: () => void
|
||||
hasSelection: () => boolean
|
||||
getState: () => SelectionState | null
|
||||
subscribe: (cb: () => void) => () => void
|
||||
shiftAnchor: (dRow: number, minRow: number, maxRow: number) => void
|
||||
shiftSelection: (dRow: number, minRow: number, maxRow: number) => void
|
||||
moveFocus: (move: FocusMove) => void
|
||||
captureScrolledRows: (firstRow: number, lastRow: number, side: 'above' | 'below') => void
|
||||
setSelectionBgColor: (color: string) => void
|
||||
}
|
||||
```
|
||||
|
||||
### `useHasSelection()`
|
||||
|
||||
Reactive boolean for selection state.
|
||||
|
||||
```ts
|
||||
function useHasSelection(): boolean
|
||||
```
|
||||
|
||||
Re-renders when selection is created or cleared.
|
||||
|
||||
---
|
||||
|
||||
## Search Hooks
|
||||
|
||||
### `useSearchHighlight()`
|
||||
|
||||
Set and manage search highlighting.
|
||||
|
||||
```ts
|
||||
function useSearchHighlight(): {
|
||||
setQuery: (query: string) => void
|
||||
scanElement: (el: DOMElement) => MatchPosition[]
|
||||
setPositions: (state: { positions: MatchPosition[]; rowOffset: number; currentIdx: number } | null) => void
|
||||
}
|
||||
```
|
||||
|
||||
### `useSearchInput(options)`
|
||||
|
||||
Search input handler with cursor management.
|
||||
|
||||
```ts
|
||||
type UseSearchInputOptions = {
|
||||
isActive: boolean
|
||||
onExit: () => void
|
||||
onCancel?: () => void
|
||||
onExitUp?: () => void
|
||||
columns?: number
|
||||
passthroughCtrlKeys?: string[]
|
||||
initialQuery?: string
|
||||
backspaceExitsOnEmpty?: boolean
|
||||
}
|
||||
|
||||
type UseSearchInputReturn = {
|
||||
query: string
|
||||
setQuery: (q: string) => void
|
||||
cursorOffset: number
|
||||
handleKeyDown: (e: KeyboardEvent) => void
|
||||
}
|
||||
|
||||
function useSearchInput(options: UseSearchInputOptions): UseSearchInputReturn
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Cursor Hooks
|
||||
|
||||
### `useDeclaredCursor(options)`
|
||||
|
||||
Park the terminal cursor at a specific position for IME and accessibility.
|
||||
|
||||
```ts
|
||||
function useDeclaredCursor({
|
||||
line: number,
|
||||
column: number,
|
||||
active: boolean
|
||||
}): (element: DOMElement | null) => void
|
||||
```
|
||||
|
||||
Returns a ref callback. Position is relative to the ref'd element.
|
||||
|
||||
Example:
|
||||
```tsx
|
||||
const cursorRef = useDeclaredCursor({
|
||||
line: 0,
|
||||
column: cursorPosition,
|
||||
active: isFocused,
|
||||
})
|
||||
|
||||
return <Box ref={cursorRef}>...</Box>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tab Status Hooks
|
||||
|
||||
### `useTabStatus(kind)`
|
||||
|
||||
Set tab status indicator (OSC 21337) for terminal tab bars.
|
||||
|
||||
```ts
|
||||
type TabStatusKind = 'idle' | 'busy' | 'waiting'
|
||||
|
||||
function useTabStatus(kind: TabStatusKind | null): void
|
||||
```
|
||||
|
||||
Pass `null` to clear.
|
||||
|
||||
---
|
||||
|
||||
## Viewport Hooks
|
||||
|
||||
### `useTerminalViewport()`
|
||||
|
||||
Track element visibility within the terminal viewport.
|
||||
|
||||
```ts
|
||||
function useTerminalViewport(): [
|
||||
ref: (element: DOMElement | null) => void,
|
||||
entry: { isVisible: boolean }
|
||||
]
|
||||
```
|
||||
|
||||
Returns a ref callback and visibility state.
|
||||
232
packages/@ant/ink/docs/10-events-and-focus.md
Normal file
232
packages/@ant/ink/docs/10-events-and-focus.md
Normal file
@@ -0,0 +1,232 @@
|
||||
# Chapter 10: Events & Focus
|
||||
|
||||
## Event System
|
||||
|
||||
Ink implements a DOM-like event system with capture/bubble phases, propagation control, and prioritized dispatch.
|
||||
|
||||
### Event Classes
|
||||
|
||||
All events extend the base `Event` class:
|
||||
|
||||
```ts
|
||||
class Event {
|
||||
stopImmediatePropagation(): void
|
||||
}
|
||||
```
|
||||
|
||||
### InputEvent
|
||||
|
||||
Emitted for every keystroke or input action.
|
||||
|
||||
```ts
|
||||
class InputEvent extends Event {
|
||||
readonly input: string // Character(s) entered
|
||||
readonly key: Key // Parsed key metadata
|
||||
readonly keypress: ParsedKey // Raw keypress data
|
||||
}
|
||||
```
|
||||
|
||||
### KeyboardEvent
|
||||
|
||||
DOM-like keyboard event for focused elements.
|
||||
|
||||
```ts
|
||||
class KeyboardEvent extends Event {
|
||||
readonly key: Key
|
||||
}
|
||||
```
|
||||
|
||||
Dispatched via `onKeyDown` / `onKeyDownCapture` on `Box`.
|
||||
|
||||
### ClickEvent
|
||||
|
||||
Mouse click event (alt-screen only).
|
||||
|
||||
```ts
|
||||
class ClickEvent extends Event {
|
||||
readonly x: number // Column (0-indexed)
|
||||
readonly y: number // Row (0-indexed)
|
||||
}
|
||||
```
|
||||
|
||||
Clicks bubble from the deepest hit Box up through ancestors.
|
||||
|
||||
### FocusEvent
|
||||
|
||||
Focus change event.
|
||||
|
||||
```ts
|
||||
class FocusEvent extends Event {
|
||||
readonly relatedTarget: DOMElement | null
|
||||
}
|
||||
```
|
||||
|
||||
### TerminalFocusEvent
|
||||
|
||||
Terminal window focus change.
|
||||
|
||||
```ts
|
||||
class TerminalFocusEvent extends Event {
|
||||
readonly type: 'terminalfocus' | 'terminalblur'
|
||||
}
|
||||
```
|
||||
|
||||
### ResizeEvent
|
||||
|
||||
Terminal resize event (internal).
|
||||
|
||||
### PasteEvent
|
||||
|
||||
Pasted text event (bracketed paste mode).
|
||||
|
||||
## Event Dispatch Flow
|
||||
|
||||
```
|
||||
stdin data → parse-keypress → InputEvent
|
||||
↓
|
||||
App.handleInput (useInput handlers)
|
||||
↓
|
||||
Box.onKeyDown (focused element, bubble)
|
||||
```
|
||||
|
||||
### Capture and Bubble Phases
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onKeyDownCapture={(e) => {
|
||||
// Capture phase: fires top-down
|
||||
console.log('Parent captures key')
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Bubble phase: fires bottom-up
|
||||
console.log('Parent receives bubbled key')
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
onKeyDown={(e) => {
|
||||
// Target: fires first in bubble phase
|
||||
console.log('Child handles key')
|
||||
e.stopImmediatePropagation() // Stop here
|
||||
}}
|
||||
>
|
||||
<Text>Focus here</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Event Propagation Methods
|
||||
|
||||
| Method | Effect |
|
||||
|--------|--------|
|
||||
| `event.stopImmediatePropagation()` | Stop all subsequent handlers |
|
||||
| `event.preventDefault()` | Not supported in terminal context |
|
||||
|
||||
## FocusManager
|
||||
|
||||
DOM-like focus management system.
|
||||
|
||||
### How Focus Works
|
||||
|
||||
1. Elements with `tabIndex >= 0` participate in Tab/Shift+Tab cycling
|
||||
2. Elements with `tabIndex === -1` are programmatically focusable only
|
||||
3. Elements with `autoFocus` receive focus on mount
|
||||
4. Clicking a focusable element focuses it
|
||||
|
||||
### Focus API
|
||||
|
||||
```ts
|
||||
class FocusManager {
|
||||
activeElement: DOMElement | null
|
||||
|
||||
focus(node: DOMElement): void
|
||||
blur(): void
|
||||
focusNext(root: DOMElement): void // Tab
|
||||
focusPrevious(root: DOMElement): void // Shift+Tab
|
||||
|
||||
handleNodeRemoved(node: DOMElement, root: DOMElement): void
|
||||
handleAutoFocus(node: DOMElement): void
|
||||
handleClickFocus(node: DOMElement): void
|
||||
|
||||
enable(): void
|
||||
disable(): void
|
||||
}
|
||||
```
|
||||
|
||||
### Tab Navigation
|
||||
|
||||
```tsx
|
||||
<Box flexDirection="column">
|
||||
<Button tabIndex={0} onAction={handleSave}>
|
||||
{(s) => <Text>{s.focused ? '> Save' : ' Save'}</Text>}
|
||||
</Button>
|
||||
<Button tabIndex={0} onAction={handleCancel}>
|
||||
{(s) => <Text>{s.focused ? '> Cancel' : ' Cancel'}</Text>}
|
||||
</Button>
|
||||
<Button tabIndex={-1} onAction={handleSecret}>
|
||||
{/* Not reachable via Tab */}
|
||||
{(s) => <Text>Secret</Text>}
|
||||
</Button>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Auto Focus
|
||||
|
||||
```tsx
|
||||
<Box tabIndex={0} autoFocus onKeyDown={handleKey}>
|
||||
<Text>Receives focus immediately on mount</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Focus Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
tabIndex={0}
|
||||
onFocus={(e) => console.log('Got focus')}
|
||||
onBlur={(e) => console.log('Lost focus')}
|
||||
onFocusCapture={(e) => console.log('Capture: focus in')}
|
||||
onBlurCapture={(e) => console.log('Capture: focus out')}
|
||||
>
|
||||
<Text>Focusable element</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
## Hit Testing
|
||||
|
||||
Mouse click/hover resolution:
|
||||
|
||||
1. Screen coordinates are mapped to DOM elements via Yoga layout
|
||||
2. The deepest element at the click position is the target
|
||||
3. Click events bubble upward through ancestors
|
||||
4. Hover events use `mouseenter`/`mouseleave` semantics (no bubbling between children)
|
||||
|
||||
### Click Hit Testing
|
||||
|
||||
```ts
|
||||
dispatchClick(rootNode, col, row): void
|
||||
```
|
||||
|
||||
Walks the DOM tree, finds the deepest Box at (col, row), fires `onClick`, then bubbles to ancestors.
|
||||
|
||||
### Hover Hit Testing
|
||||
|
||||
```ts
|
||||
dispatchHover(rootNode, col, row, hoveredNodes): void
|
||||
```
|
||||
|
||||
Tracks which nodes are under the pointer. Fires `onMouseEnter`/`onMouseLeave` as the pointer moves between elements.
|
||||
|
||||
## EventEmitter
|
||||
|
||||
Custom event emitter for internal use:
|
||||
|
||||
```ts
|
||||
class EventEmitter {
|
||||
on(event: string, handler: Function): void
|
||||
off(event: string, handler: Function): void
|
||||
emit(event: string, ...args: any[]): void
|
||||
removeListener(event: string, handler: Function): void
|
||||
}
|
||||
```
|
||||
|
||||
Used internally by the Ink instance for `input` events.
|
||||
301
packages/@ant/ink/docs/11-core-architecture.md
Normal file
301
packages/@ant/ink/docs/11-core-architecture.md
Normal file
@@ -0,0 +1,301 @@
|
||||
# Chapter 11: Core Architecture
|
||||
|
||||
This chapter covers the internal rendering pipeline, DOM model, and screen buffer system. This is advanced material -- most users only need the component and hooks APIs.
|
||||
|
||||
## Rendering Pipeline
|
||||
|
||||
```
|
||||
React Component Tree
|
||||
↓ (React reconciler)
|
||||
Ink DOM Tree (virtual terminal DOM)
|
||||
↓ (Yoga layout)
|
||||
Positioned DOM Tree (computed x, y, width, height)
|
||||
↓ (renderNodeToOutput)
|
||||
Output Buffer (styled characters)
|
||||
↓ (renderer → Screen)
|
||||
Screen Buffer (Int32Array of cells)
|
||||
↓ (diffEach)
|
||||
ANSI Diff Patches (minimal escape sequences)
|
||||
↓ (writeDiffToTerminal)
|
||||
Terminal stdout
|
||||
```
|
||||
|
||||
### Frame Lifecycle
|
||||
|
||||
Each render cycle (`onRender`) follows these phases:
|
||||
|
||||
1. **React Commit** -- React reconciles the virtual tree; host config updates Ink DOM
|
||||
2. **Yoga Layout** -- All dirty nodes have their styles applied and layout computed
|
||||
3. **Renderer** -- Creates Output buffer, calls `renderNodeToOutput` for the full tree
|
||||
4. **Screen Diff** -- New frame is compared against previous frame cell-by-cell
|
||||
5. **Optimize** -- Patches are merged and ordered for minimal cursor movement
|
||||
6. **Write** -- ANSI escape sequences are written to stdout
|
||||
|
||||
### Frame Timing
|
||||
|
||||
```ts
|
||||
const FRAME_INTERVAL_MS = 16 // ~60fps cap
|
||||
```
|
||||
|
||||
Renders are throttled. Multiple state updates in one frame are batched.
|
||||
|
||||
### Double Buffering
|
||||
|
||||
Two frames are maintained:
|
||||
|
||||
- **`frontFrame`** -- The currently displayed frame
|
||||
- **`backFrame`** -- The frame being rendered
|
||||
|
||||
After rendering, they are swapped. This prevents partial updates from being visible.
|
||||
|
||||
## Ink DOM
|
||||
|
||||
### Node Types
|
||||
|
||||
```ts
|
||||
type ElementNames =
|
||||
| 'ink-root' // Root container
|
||||
| 'ink-box' // Box component
|
||||
| 'ink-text' // Text component
|
||||
| 'ink-virtual-text' // Intermediate text wrapper
|
||||
| 'ink-link' // Link component
|
||||
| 'ink-raw-ansi' // Raw ANSI content
|
||||
```
|
||||
|
||||
### DOMElement
|
||||
|
||||
```ts
|
||||
type DOMElement = {
|
||||
nodeName: ElementNames
|
||||
attributes: Record<string, unknown>
|
||||
childNodes: DOMNode[] // DOMElement | TextNode
|
||||
yogaNode?: LayoutNode // Yoga layout node
|
||||
textStyles?: TextStyles // Inherited text styles
|
||||
|
||||
// Scroll state
|
||||
scrollTop?: number
|
||||
scrollHeight?: number
|
||||
scrollViewportHeight?: number
|
||||
scrollViewportTop?: number
|
||||
stickyScroll?: boolean
|
||||
pendingScrollDelta?: number
|
||||
scrollAnchor?: { el: DOMElement; offset: number }
|
||||
|
||||
// Dirty tracking
|
||||
dirty: boolean
|
||||
|
||||
// Event handlers (stored separately)
|
||||
onClick?: (event: ClickEvent) => void
|
||||
onFocus?: (event: FocusEvent) => void
|
||||
onBlur?: (event: FocusEvent) => void
|
||||
onKeyDown?: (event: KeyboardEvent) => void
|
||||
onMouseEnter?: () => void
|
||||
onMouseLeave?: () => void
|
||||
}
|
||||
```
|
||||
|
||||
### TextNode
|
||||
|
||||
```ts
|
||||
type TextNode = {
|
||||
nodeName: '#text'
|
||||
nodeValue: string
|
||||
yogaNode?: LayoutNode
|
||||
}
|
||||
```
|
||||
|
||||
### DOM Operations
|
||||
|
||||
```ts
|
||||
// Node creation
|
||||
createNode(nodeName: string): DOMElement
|
||||
createTextNode(text: string): TextNode
|
||||
|
||||
// Tree manipulation
|
||||
appendChildNode(parent: DOMElement, child: DOMNode): void
|
||||
insertBeforeNode(parent: DOMElement, child: DOMNode, before: DOMNode): void
|
||||
removeChildNode(parent: DOMElement, child: DOMNode): void
|
||||
|
||||
// Attribute manipulation
|
||||
setAttribute(node: DOMElement, key: string, value: unknown): void
|
||||
setStyle(node: DOMElement, style: Styles): void
|
||||
setTextStyles(node: DOMElement, styles: TextStyles): void
|
||||
|
||||
// Dirty tracking
|
||||
markDirty(node: DOMElement): void
|
||||
scheduleRenderFrom(node: DOMElement): void
|
||||
```
|
||||
|
||||
## Screen Buffer
|
||||
|
||||
### Cell Storage
|
||||
|
||||
The screen buffer uses packed `Int32Array` storage for memory efficiency:
|
||||
|
||||
```ts
|
||||
type Screen = {
|
||||
width: number
|
||||
height: number
|
||||
cells: Int32Array // 2 Int32s per cell: [charId, packed_style_hyperlink_width]
|
||||
cells64: BigInt64Array // For bulk fill operations
|
||||
charPool: CharPool // String interning
|
||||
stylePool: StylePool // ANSI code interning
|
||||
hyperlinkPool: HyperlinkPool
|
||||
emptyStyleId: number
|
||||
damage: Rectangle | undefined // Bounding box of changed cells
|
||||
noSelect: Uint8Array // Per-cell no-select bitmap
|
||||
softWrap: Int32Array // Per-row soft-wrap markers
|
||||
}
|
||||
```
|
||||
|
||||
### Cell Width
|
||||
|
||||
```ts
|
||||
enum CellWidth {
|
||||
Narrow = 0, // Regular character (1 column)
|
||||
Wide = 1, // CJK/emoji (2 columns)
|
||||
SpacerTail = 2, // Right half of wide character
|
||||
SpacerHead = 3, // Soft-wrapped wide character
|
||||
}
|
||||
```
|
||||
|
||||
### Style Pool
|
||||
|
||||
ANSI style codes are interned for efficiency:
|
||||
|
||||
```ts
|
||||
class StylePool {
|
||||
intern(codes: AnsiCode[]): number // Returns compact ID
|
||||
get(id: number): AnsiCode[]
|
||||
transition(from: number, to: number): string // Cached ANSI transition
|
||||
withInverse(id: number): number // Selection overlay
|
||||
setSelectionBg(bg: AnsiCode): void // Theme-aware selection bg
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Algorithm
|
||||
|
||||
```ts
|
||||
diffEach(prev: Screen, next: Screen, callback: (x, y, oldCell, newCell) => void): void
|
||||
```
|
||||
|
||||
Only iterates cells within the damage bounding box. Unchanged regions are skipped entirely.
|
||||
|
||||
### Screen Operations
|
||||
|
||||
```ts
|
||||
createScreen(width, height, stylePool, charPool, hyperlinkPool): Screen
|
||||
setCellAt(screen, x, y, cell): void
|
||||
cellAt(screen, x, y): Cell
|
||||
clearRegion(screen, x, y, width, height): void
|
||||
blitRegion(dst, src, x, y, maxX, maxY): void
|
||||
shiftRows(screen, top, bottom, n): void
|
||||
```
|
||||
|
||||
## Layout Engine
|
||||
|
||||
### Yoga Integration
|
||||
|
||||
Ink wraps Facebook's Yoga layout engine for Flexbox computation:
|
||||
|
||||
```ts
|
||||
// Layout node types
|
||||
enum LayoutDisplay { Flex, None }
|
||||
enum LayoutPositionType { Absolute, Relative }
|
||||
enum LayoutOverflow { Visible, Hidden, Scroll }
|
||||
enum LayoutFlexDirection { Row, Column, RowReverse, ColumnReverse }
|
||||
enum LayoutWrap { NoWrap, Wrap, WrapReverse }
|
||||
enum LayoutAlign { FlexStart, Center, FlexEnd, Stretch }
|
||||
enum LayoutJustify { FlexStart, Center, FlexEnd, SpaceBetween, SpaceAround, SpaceEvenly }
|
||||
enum LayoutEdge { Top, Bottom, Left, Right, Start, End, Horizontal, Vertical, All }
|
||||
enum LayoutGutter { Column, Row, All }
|
||||
```
|
||||
|
||||
### Style Application
|
||||
|
||||
Styles from React props are applied to Yoga nodes during the commit phase:
|
||||
|
||||
```ts
|
||||
function styles(node: LayoutNode, style: Styles, resolvedStyle?: Styles): void
|
||||
```
|
||||
|
||||
This function maps each CSS-like prop to the corresponding Yoga setter.
|
||||
|
||||
## Output Buffer
|
||||
|
||||
Intermediate rendering target before screen diff:
|
||||
|
||||
```ts
|
||||
class Output {
|
||||
write(text: string, x: number, y: number, styles: TextStyles): void
|
||||
wrap(width: number, textWrap: TextWrap): void
|
||||
}
|
||||
```
|
||||
|
||||
`renderNodeToOutput` walks the DOM tree and writes styled characters into this buffer.
|
||||
|
||||
## Reconciler
|
||||
|
||||
Custom React reconciler that bridges React and the Ink DOM:
|
||||
|
||||
- **Host config** -- Defines how React operations map to Ink DOM mutations
|
||||
- **Concurrent mode** -- Supports `ConcurrentRoot` for React 19 features
|
||||
- **Yoga integration** -- Applies styles during commit phase
|
||||
- **DevTools** -- Connected in development mode
|
||||
|
||||
### Host Config Methods
|
||||
|
||||
| Method | Purpose |
|
||||
|--------|---------|
|
||||
| `createInstance` | Create `ink-box`, `ink-text`, etc. |
|
||||
| `createTextInstance` | Create `#text` node |
|
||||
| `appendChildNode` | Add child to parent |
|
||||
| `removeChildNode` | Remove child from parent |
|
||||
| `insertBefore` | Insert child before sibling |
|
||||
| `commitUpdate` | Update element attributes/styles |
|
||||
| `commitTextUpdate` | Update text content |
|
||||
| `getPublicInstance` | Return DOMElement for refs |
|
||||
|
||||
## Performance Optimizations
|
||||
|
||||
1. **String Interning** -- CharPool deduplicates character strings across frames
|
||||
2. **Style Caching** -- StylePool caches ANSI transition strings
|
||||
3. **Damage Tracking** -- Only diff cells within the changed bounding box
|
||||
4. **Bulk Operations** -- `Int32Array.set()` for fast region blit
|
||||
5. **Throttled Rendering** -- Frame rate capped at ~60fps
|
||||
6. **Viewport Culling** -- ScrollBox only renders visible children
|
||||
7. **Microtask Coalescing** -- Multiple scroll deltas merged into one render
|
||||
|
||||
## Frame Events
|
||||
|
||||
Debug instrumentation for render performance:
|
||||
|
||||
```ts
|
||||
type FrameEvent = {
|
||||
durationMs: number
|
||||
phases: {
|
||||
renderer: number // Yoga + renderNodeToOutput
|
||||
diff: number // Screen diff
|
||||
optimize: number // Patch optimization
|
||||
write: number // Terminal write
|
||||
patches: number // Number of ANSI patches
|
||||
yoga: number // Yoga layout time
|
||||
commit: number // React commit time
|
||||
yogaVisited: number // Yoga nodes visited
|
||||
yogaMeasured: number // Yoga nodes measured
|
||||
yogaCacheHits: number // Cached measurements
|
||||
yogaLive: number // Active Yoga nodes
|
||||
}
|
||||
flickers: FlickerReason[]
|
||||
}
|
||||
```
|
||||
|
||||
Enable with `onFrame` in RenderOptions:
|
||||
|
||||
```tsx
|
||||
render(<App />, {
|
||||
onFrame: (event) => {
|
||||
console.log(`Frame: ${event.durationMs}ms`)
|
||||
}
|
||||
})
|
||||
```
|
||||
381
packages/@ant/ink/docs/12-terminal-integration.md
Normal file
381
packages/@ant/ink/docs/12-terminal-integration.md
Normal file
@@ -0,0 +1,381 @@
|
||||
# Chapter 12: Terminal Integration
|
||||
|
||||
This chapter covers terminal-specific features: alternate screen, mouse tracking, clipboard, notifications, and terminal querying.
|
||||
|
||||
## Alternate Screen
|
||||
|
||||
Enter a fullscreen alternate screen buffer (like vim, less, htop).
|
||||
|
||||
```tsx
|
||||
import { AlternateScreen } from '@anthropic/ink'
|
||||
|
||||
<AlternateScreen mouseTracking={true}>
|
||||
<Box flexDirection="column" height="100%">
|
||||
<Text>Fullscreen content</Text>
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
```
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | Type | Default | Description |
|
||||
|------|------|---------|-------------|
|
||||
| `children` | `ReactNode` | - | Content |
|
||||
| `mouseTracking` | `boolean` | `true` | Enable SGR mouse tracking |
|
||||
|
||||
### Behavior
|
||||
|
||||
On mount:
|
||||
1. Enters DEC 1049 alternate screen buffer
|
||||
2. Hides cursor
|
||||
3. Enables mouse tracking (if `mouseTracking=true`)
|
||||
4. Constrains rendering height to terminal rows
|
||||
|
||||
On unmount:
|
||||
1. Exits alternate screen buffer
|
||||
2. Shows cursor
|
||||
3. Disables mouse tracking
|
||||
4. Restores original terminal content
|
||||
|
||||
### Mouse Tracking Modes
|
||||
|
||||
When enabled:
|
||||
- **Mode 1003** -- Button press/release + motion (hover)
|
||||
- **Mode 1006** -- SGR extended mouse format (coordinates > 223)
|
||||
- **Wheel events** -- Scroll up/down
|
||||
|
||||
### External Editor Handoff
|
||||
|
||||
The Ink instance supports pausing for an external editor:
|
||||
|
||||
```ts
|
||||
// Pause Ink, run external command, resume
|
||||
ink.enterAlternateScreen() // Save state
|
||||
// ... external editor runs ...
|
||||
ink.reassertTerminalModes() // Restore on resume
|
||||
```
|
||||
|
||||
This is triggered by Ctrl+Z (SIGTSTP) and SIGCONT.
|
||||
|
||||
## Mouse Events
|
||||
|
||||
### Click Events
|
||||
|
||||
```tsx
|
||||
<Box onClick={(event) => {
|
||||
console.log(`Clicked at col=${event.x}, row=${event.y}`)
|
||||
event.stopImmediatePropagation()
|
||||
}}>
|
||||
<Text>Clickable area</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Multi-Click
|
||||
|
||||
Double-click selects a word, triple-click selects a line. Handled by the App component:
|
||||
|
||||
```ts
|
||||
// App prop
|
||||
onMultiClick: (col: number, row: number, count: 2 | 3) => void
|
||||
```
|
||||
|
||||
### Hover Events
|
||||
|
||||
```tsx
|
||||
<Box
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<Text>{hovered ? 'Hovered!' : 'Hover me'}</Text>
|
||||
</Box>
|
||||
```
|
||||
|
||||
Hover uses `mouseenter`/`mouseleave` semantics (no bubbling between children).
|
||||
|
||||
### Drag-to-Select
|
||||
|
||||
In alt-screen mode, click-drag creates a text selection:
|
||||
|
||||
```ts
|
||||
// App prop
|
||||
onSelectionDrag: (col: number, row: number) => void
|
||||
```
|
||||
|
||||
## Clipboard
|
||||
|
||||
### OSC 52 Clipboard
|
||||
|
||||
```tsx
|
||||
import { setClipboard } from '@anthropic/ink'
|
||||
|
||||
await setClipboard('Copied text')
|
||||
```
|
||||
|
||||
### Copy Selection
|
||||
|
||||
```tsx
|
||||
const { copySelection } = useSelection()
|
||||
const text = copySelection() // Copies to clipboard and clears highlight
|
||||
```
|
||||
|
||||
### Copy Without Clear
|
||||
|
||||
```tsx
|
||||
const { copySelectionNoClear } = useSelection()
|
||||
const text = copySelectionNoClear() // Copies but keeps highlight
|
||||
```
|
||||
|
||||
## Terminal Notifications
|
||||
|
||||
Send desktop notifications from the terminal.
|
||||
|
||||
```tsx
|
||||
import { useTerminalNotification } from '@anthropic/ink'
|
||||
|
||||
function MyComponent() {
|
||||
const { notifyBell, progress } = useTerminalNotification()
|
||||
|
||||
// Terminal bell (audible/system notification)
|
||||
notifyBell()
|
||||
|
||||
// Progress bar in terminal title/tab
|
||||
progress('running', 65) // 65% complete
|
||||
progress('completed') // Done
|
||||
progress('error') // Error state
|
||||
progress('indeterminate') // Unknown progress
|
||||
progress(null) // Clear
|
||||
}
|
||||
```
|
||||
|
||||
### Terminal-Specific Notifications
|
||||
|
||||
```tsx
|
||||
const { notifyITerm2, notifyKitty, notifyGhostty } = useTerminalNotification()
|
||||
|
||||
// iTerm2
|
||||
notifyITerm2({ message: 'Build complete', title: 'My App' })
|
||||
|
||||
// Kitty
|
||||
notifyKitty({ message: 'Build complete', title: 'My App', id: 1 })
|
||||
|
||||
// Ghostty
|
||||
notifyGhostty({ message: 'Build complete', title: 'My App' })
|
||||
```
|
||||
|
||||
## Terminal Queries
|
||||
|
||||
### Background Color (OSC 11)
|
||||
|
||||
Used for auto-theme detection:
|
||||
|
||||
```ts
|
||||
import { getTerminalBackground } from '@anthropic/ink'
|
||||
const bg = await getTerminalBackground()
|
||||
// e.g., 'rgb:0000/0000/0000' (dark) or 'rgb:ffff/ffff/ffff' (light)
|
||||
```
|
||||
|
||||
### Terminal Version (XTVERSION)
|
||||
|
||||
```ts
|
||||
import { isXtermJs, setXtversionName, getXtversionName } from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Feature Detection
|
||||
|
||||
```ts
|
||||
import { supportsHyperlinks } from '@anthropic/ink'
|
||||
|
||||
if (supportsHyperlinks()) {
|
||||
// OSC 8 hyperlinks supported
|
||||
}
|
||||
|
||||
import { supportsExtendedKeys } from '@anthropic/ink'
|
||||
|
||||
if (supportsExtendedKeys()) {
|
||||
// Kitty keyboard protocol / modifyOtherKeys available
|
||||
}
|
||||
```
|
||||
|
||||
## Terminal Focus
|
||||
|
||||
Track terminal window focus/unfocus:
|
||||
|
||||
```tsx
|
||||
import { useTerminalFocus } from '@anthropic/ink'
|
||||
|
||||
const isFocused = useTerminalFocus()
|
||||
```
|
||||
|
||||
Low-level API:
|
||||
|
||||
```ts
|
||||
import { getTerminalFocused, subscribeTerminalFocus } from '@anthropic/ink'
|
||||
|
||||
getTerminalFocused() // boolean
|
||||
subscribeTerminalFocus((focused: boolean) => {
|
||||
// Called on focus change
|
||||
})
|
||||
```
|
||||
|
||||
Uses DECSET 1004 focus reporting.
|
||||
|
||||
## Terminal Title
|
||||
|
||||
Set the terminal window title:
|
||||
|
||||
```tsx
|
||||
import { useTerminalTitle } from '@anthropic/ink'
|
||||
|
||||
useTerminalTitle('My App - Dashboard')
|
||||
```
|
||||
|
||||
Clear:
|
||||
|
||||
```tsx
|
||||
useTerminalTitle(null)
|
||||
```
|
||||
|
||||
## Terminal I/O Sequences
|
||||
|
||||
Low-level ANSI sequence constants for advanced use.
|
||||
|
||||
### Cursor Control
|
||||
|
||||
```ts
|
||||
import {
|
||||
SHOW_CURSOR,
|
||||
HIDE_CURSOR,
|
||||
CURSOR_HOME,
|
||||
} from '@anthropic/ink'
|
||||
|
||||
// cursorPosition(row, col) -- Move cursor to absolute position
|
||||
// cursorMove(dx, dy) -- Move cursor relative
|
||||
```
|
||||
|
||||
### Screen Control
|
||||
|
||||
```ts
|
||||
import {
|
||||
ENTER_ALT_SCREEN,
|
||||
EXIT_ALT_SCREEN,
|
||||
ERASE_SCREEN,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Mouse Control
|
||||
|
||||
```ts
|
||||
import {
|
||||
ENABLE_MOUSE_TRACKING,
|
||||
DISABLE_MOUSE_TRACKING,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Keyboard Protocols
|
||||
|
||||
```ts
|
||||
import {
|
||||
ENABLE_KITTY_KEYBOARD,
|
||||
DISABLE_KITTY_KEYBOARD,
|
||||
ENABLE_MODIFY_OTHER_KEYS,
|
||||
DISABLE_MODIFY_OTHER_KEYS,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
### Clipboard & Tab Status
|
||||
|
||||
```ts
|
||||
import {
|
||||
CLEAR_ITERM2_PROGRESS,
|
||||
CLEAR_TAB_STATUS,
|
||||
CLEAR_TERMINAL_TITLE,
|
||||
wrapForMultiplexer,
|
||||
} from '@anthropic/ink'
|
||||
```
|
||||
|
||||
`wrapForMultiplexer` wraps OSC sequences for tmux compatibility.
|
||||
|
||||
## Terminal Compatibility
|
||||
|
||||
### Supported Terminals
|
||||
|
||||
| Terminal | Features |
|
||||
|----------|----------|
|
||||
| iTerm2 | Full support (hyperlinks, notifications, progress) |
|
||||
| Kitty | Full support (keyboard protocol, notifications) |
|
||||
| Ghostty | Full support |
|
||||
| WezTerm | Full support |
|
||||
| Alacritty | Most features |
|
||||
| Windows Terminal | Most features |
|
||||
| Apple Terminal | 256-color fallback |
|
||||
| xterm.js (VS Code) | Detected and special-cased |
|
||||
| tmux | Wrapped sequences via `wrapForMultiplexer` |
|
||||
| Screen | Basic support |
|
||||
|
||||
### Feature Degradation
|
||||
|
||||
The framework gracefully degrades:
|
||||
- No true color → Falls back to ANSI 16-color themes
|
||||
- No OSC 52 → Clipboard operations silently fail
|
||||
- No mouse tracking → Click/hover events are no-ops
|
||||
- No extended keys → Standard escape sequences used
|
||||
- No bracketed paste → Paste detected by timing heuristic
|
||||
|
||||
### Synchronized Output
|
||||
|
||||
```ts
|
||||
import { isSynchronizedOutputSupported } from '@anthropic/ink'
|
||||
|
||||
if (isSynchronizedOutputSupported()) {
|
||||
// BSU/ESU for tear-free rendering
|
||||
}
|
||||
```
|
||||
|
||||
Uses DECSET 2026 synchronized output to prevent partial frame display.
|
||||
|
||||
### Bracketed Paste
|
||||
|
||||
Uses DECSET 2004 to distinguish paste events from rapid typing. Automatically enabled by the App component.
|
||||
|
||||
## Text Selection (Alt-Screen)
|
||||
|
||||
### Selection State
|
||||
|
||||
```ts
|
||||
type SelectionState = {
|
||||
anchor: Point | null // Drag start
|
||||
focus: Point | null // Current position
|
||||
isDragging: boolean
|
||||
anchorSpan: { lo: Point; hi: Point; kind: 'word' | 'line' } | null
|
||||
scrolledOffAbove: string[] // Text scrolled out above
|
||||
scrolledOffBelow: string[] // Text scrolled out below
|
||||
}
|
||||
```
|
||||
|
||||
### Selection Operations
|
||||
|
||||
- **Click-drag** -- Free-form selection
|
||||
- **Double-click** -- Word selection
|
||||
- **Triple-click** -- Line selection
|
||||
- **Shift+Arrow** -- Extend selection from keyboard
|
||||
- **Drag-to-scroll** -- Auto-scroll when dragging near edges
|
||||
|
||||
### noSelect Regions
|
||||
|
||||
Exclude areas from selection (gutters, line numbers):
|
||||
|
||||
```tsx
|
||||
<Box noSelect={true}>
|
||||
<Text>1 │</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text>code here</Text> {/* Only this is selectable */}
|
||||
</Box>
|
||||
```
|
||||
|
||||
### Soft-Wrap Awareness
|
||||
|
||||
Selection correctly handles text that was wrapped across multiple rows:
|
||||
- Wrapped lines are joined when copied
|
||||
- Trailing whitespace is trimmed
|
||||
- The `softWrap` bitmap tracks which rows are continuations
|
||||
46
packages/@ant/ink/docs/README.md
Normal file
46
packages/@ant/ink/docs/README.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# @anthropic/ink Documentation
|
||||
|
||||
A terminal React rendering framework for building rich command-line interfaces.
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
`@anthropic/ink` is a forked/internal Ink framework that renders React components directly to the terminal using ANSI escape sequences. It uses Yoga (via a custom layout engine) for Flexbox layout, a custom React reconciler for terminal DOM, and a screen-buffer differ for efficient updates.
|
||||
|
||||
### Three-Layer Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Layer 3: Theme │
|
||||
│ ThemeProvider, ThemedBox, ThemedText, │
|
||||
│ Dialog, FuzzyPicker, ProgressBar, etc. │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Layer 2: Components │
|
||||
│ Box, Text, ScrollBox, Button, Link, │
|
||||
│ Newline, Spacer, AlternateScreen │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Layer 1: Core │
|
||||
│ Reconciler, Layout (Yoga), Terminal │
|
||||
│ I/O, Screen Buffer, Event System │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Core** (`src/core/`) -- Rendering engine: React reconciler, Yoga flexbox layout, terminal I/O, screen buffer with diff-based updates, event system (keyboard, mouse, focus, click).
|
||||
- **Components** (`src/components/`) -- UI primitives: `Box`, `Text`, `ScrollBox`, `Button`, `Link`, `Newline`, `Spacer`, etc. Plus context providers (`App`, `StdinContext`).
|
||||
- **Theme** (`src/theme/`) -- Theme system: `ThemeProvider`, theme-aware `Box`/`Text` wrappers, and design-system components (`Dialog`, `FuzzyPicker`, `ProgressBar`, `Tabs`, etc.).
|
||||
|
||||
### Documentation
|
||||
|
||||
| Chapter | File | Contents |
|
||||
|---------|------|----------|
|
||||
| 1 | [Getting Started](./01-getting-started.md) | Installation, rendering, basic concepts |
|
||||
| 2 | [Layout System](./02-layout.md) | Box, Flexbox, Yoga, positioning, dimensions |
|
||||
| 3 | [Text & Styling](./03-text-and-styling.md) | Text component, colors, text wrapping, ANSI styling |
|
||||
| 4 | [Theme System](./04-theme-system.md) | ThemeProvider, themes, ThemedBox, ThemedText, color() |
|
||||
| 5 | [Design System Components](./05-design-system.md) | Dialog, ProgressBar, FuzzyPicker, Tabs, Spinner, etc. |
|
||||
| 6 | [Scrolling](./06-scrolling.md) | ScrollBox, sticky scroll, imperative scroll API |
|
||||
| 7 | [User Input](./07-user-input.md) | useInput, Key types, raw mode, mouse events |
|
||||
| 8 | [Keybinding System](./08-keybindings.md) | KeybindingProvider, useKeybinding, chord sequences, parser |
|
||||
| 9 | [Hooks Reference](./09-hooks-reference.md) | All hooks with full API signatures |
|
||||
| 10 | [Events & Focus](./10-events-and-focus.md) | Event system, FocusManager, click/hover, tab navigation |
|
||||
| 11 | [Core Architecture](./11-core-architecture.md) | Reconciler, screen buffer, terminal I/O, rendering pipeline |
|
||||
| 12 | [Terminal Integration](./12-terminal-integration.md) | Alternate screen, mouse tracking, clipboard, notifications |
|
||||
11
packages/agent-tools/package.json
Normal file
11
packages/agent-tools/package.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "@claude-code-best/agent-tools",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"zod": "^3.25.0"
|
||||
}
|
||||
}
|
||||
34
packages/agent-tools/src/__tests__/compat.test.ts
Normal file
34
packages/agent-tools/src/__tests__/compat.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import type { CoreTool, Tool, Tools, AnyObject, ToolResult, ValidationResult, PermissionResult } from '@claude-code-best/agent-tools'
|
||||
import type { Tool as HostTool } from '../../src/Tool.js'
|
||||
|
||||
describe('agent-tools compatibility', () => {
|
||||
test('CoreTool structural compatibility with host Tool', () => {
|
||||
// The host's Tool should structurally satisfy CoreTool
|
||||
// because it has all required fields (name, call, description, etc.)
|
||||
// This test verifies the type-level compatibility at runtime
|
||||
const mockHostTool: HostTool = {
|
||||
name: 'test',
|
||||
aliases: [],
|
||||
searchHint: 'test tool',
|
||||
inputSchema: {} as any,
|
||||
async call() { return { data: 'ok' } as any },
|
||||
async description() { return 'test' },
|
||||
async prompt() { return 'test prompt' },
|
||||
isConcurrencySafe: () => false,
|
||||
isEnabled: () => true,
|
||||
isReadOnly: () => false,
|
||||
async checkPermissions() { return { behavior: 'allow' as const, updatedInput: {} } },
|
||||
toAutoClassifierInput: () => '',
|
||||
userFacingName: () => 'test',
|
||||
maxResultSizeChars: 100000,
|
||||
mapToolResultToToolResultBlockParam: () => ({ type: 'tool_result', tool_use_id: '1', content: 'ok' }),
|
||||
renderToolUseMessage: () => null,
|
||||
}
|
||||
|
||||
// This assignment should work if HostTool structurally extends CoreTool
|
||||
const coreTool: CoreTool = mockHostTool as CoreTool
|
||||
expect(coreTool.name).toBe('test')
|
||||
expect(coreTool.isEnabled()).toBe(true)
|
||||
})
|
||||
})
|
||||
63
packages/agent-tools/src/__tests__/registry.test.ts
Normal file
63
packages/agent-tools/src/__tests__/registry.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { findToolByName, toolMatchesName } from '../registry.js'
|
||||
import type { CoreTool, Tools } from '../types.js'
|
||||
|
||||
describe('toolMatchesName', () => {
|
||||
test('matches primary name', () => {
|
||||
expect(toolMatchesName({ name: 'bash' }, 'bash')).toBe(true)
|
||||
})
|
||||
|
||||
test('does not match different name', () => {
|
||||
expect(toolMatchesName({ name: 'bash' }, 'read')).toBe(false)
|
||||
})
|
||||
|
||||
test('matches alias', () => {
|
||||
expect(toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'shell')).toBe(true)
|
||||
expect(toolMatchesName({ name: 'bash', aliases: ['shell', 'sh'] }, 'sh')).toBe(true)
|
||||
})
|
||||
|
||||
test('handles empty aliases', () => {
|
||||
expect(toolMatchesName({ name: 'bash', aliases: [] }, 'bash')).toBe(true)
|
||||
expect(toolMatchesName({ name: 'bash', aliases: [] }, 'shell')).toBe(false)
|
||||
})
|
||||
|
||||
test('handles undefined aliases', () => {
|
||||
expect(toolMatchesName({ name: 'bash' }, 'bash')).toBe(true)
|
||||
expect(toolMatchesName({ name: 'bash' }, 'shell')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('findToolByName', () => {
|
||||
const tools: Tools = [
|
||||
{ name: 'bash' } as CoreTool,
|
||||
{ name: 'read', aliases: ['cat'] } as CoreTool,
|
||||
{ name: 'write', aliases: ['edit'] } as CoreTool,
|
||||
]
|
||||
|
||||
test('finds tool by primary name', () => {
|
||||
expect(findToolByName(tools, 'bash')?.name).toBe('bash')
|
||||
})
|
||||
|
||||
test('finds tool by alias', () => {
|
||||
expect(findToolByName(tools, 'cat')?.name).toBe('read')
|
||||
expect(findToolByName(tools, 'edit')?.name).toBe('write')
|
||||
})
|
||||
|
||||
test('returns undefined for unknown name', () => {
|
||||
expect(findToolByName(tools, 'unknown')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('handles empty tools array', () => {
|
||||
expect(findToolByName([], 'bash')).toBeUndefined()
|
||||
})
|
||||
|
||||
test('returns first match for duplicate names', () => {
|
||||
const dupTools: Tools = [
|
||||
{ name: 'tool', aliases: ['a'] } as CoreTool,
|
||||
{ name: 'tool', aliases: ['b'] } as CoreTool,
|
||||
]
|
||||
const found = findToolByName(dupTools, 'tool')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.aliases).toContain('a')
|
||||
})
|
||||
})
|
||||
18
packages/agent-tools/src/index.ts
Normal file
18
packages/agent-tools/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// agent-tools — Tool interface definitions and registry utilities
|
||||
// Pure types + pure functions, zero runtime dependencies
|
||||
|
||||
export type {
|
||||
AnyObject,
|
||||
ToolInputJSONSchema,
|
||||
ToolProgressData,
|
||||
ToolProgress,
|
||||
ToolCallProgress,
|
||||
ToolResult,
|
||||
ValidationResult,
|
||||
PermissionResult,
|
||||
CoreTool,
|
||||
Tool,
|
||||
Tools,
|
||||
} from './types.js'
|
||||
|
||||
export { findToolByName, toolMatchesName } from './registry.js'
|
||||
21
packages/agent-tools/src/registry.ts
Normal file
21
packages/agent-tools/src/registry.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { CoreTool, Tools } from './types.js'
|
||||
|
||||
/**
|
||||
* Checks if a tool matches the given name (primary name or alias).
|
||||
*/
|
||||
export function toolMatchesName(
|
||||
tool: { name: string; aliases?: string[] },
|
||||
name: string,
|
||||
): boolean {
|
||||
return tool.name === name || (tool.aliases?.includes(name) ?? false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a tool by name or alias from a list of tools.
|
||||
*/
|
||||
export function findToolByName(
|
||||
tools: Tools,
|
||||
name: string,
|
||||
): CoreTool | undefined {
|
||||
return tools.find(t => toolMatchesName(t, name))
|
||||
}
|
||||
221
packages/agent-tools/src/types.ts
Normal file
221
packages/agent-tools/src/types.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// agent-tools — Core Tool interface definitions
|
||||
// Protocol-level types, independent of any host framework
|
||||
|
||||
import type { z } from 'zod/v4'
|
||||
|
||||
// ============================================================================
|
||||
// Schema types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Zod schema type for any object with string keys.
|
||||
* Used as the Input generic constraint for Tool.
|
||||
*/
|
||||
export type AnyObject = z.ZodType<{ [key: string]: unknown }>
|
||||
|
||||
/**
|
||||
* JSON Schema format for MCP tool input schemas.
|
||||
* MCP servers provide this directly instead of Zod schemas.
|
||||
*/
|
||||
export type ToolInputJSONSchema = {
|
||||
[x: string]: unknown
|
||||
type: 'object'
|
||||
properties?: {
|
||||
[x: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Progress types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Progress data from a running tool. Host defines concrete subtypes.
|
||||
* Typed as `any` at the protocol level — the host assigns real shapes.
|
||||
*/
|
||||
export type ToolProgressData = any
|
||||
|
||||
/**
|
||||
* A progress event from a tool execution.
|
||||
*/
|
||||
export type ToolProgress<P extends ToolProgressData = ToolProgressData> = {
|
||||
toolUseID: string
|
||||
data: P
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for receiving progress updates during tool execution.
|
||||
*/
|
||||
export type ToolCallProgress<P extends ToolProgressData = ToolProgressData> = (
|
||||
progress: ToolProgress<P>,
|
||||
) => void
|
||||
|
||||
// ============================================================================
|
||||
// Result types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result returned by a tool's call() method.
|
||||
* @template T - The output data type
|
||||
* @template Message - The message type (host-specific, defaults to unknown)
|
||||
*/
|
||||
export type ToolResult<T, Message = unknown> = {
|
||||
data: T
|
||||
newMessages?: Message[]
|
||||
contextModifier?: (context: any) => any
|
||||
/** MCP protocol metadata (structuredContent, _meta) */
|
||||
mcpMeta?: {
|
||||
_meta?: Record<string, unknown>
|
||||
structuredContent?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Validation & Permission types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Result of tool input validation.
|
||||
*/
|
||||
export type ValidationResult =
|
||||
| { result: true }
|
||||
| { result: false; message: string; errorCode: number }
|
||||
|
||||
/**
|
||||
* Result of a permission check for a tool invocation.
|
||||
*/
|
||||
export type PermissionResult =
|
||||
| { behavior: 'allow'; updatedInput: Record<string, unknown> }
|
||||
| { behavior: 'deny'; message: string }
|
||||
| { behavior: 'passthrough' }
|
||||
|
||||
// ============================================================================
|
||||
// Core Tool interface
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* The host-agnostic core Tool interface.
|
||||
*
|
||||
* This defines the protocol-level contract for any tool — independent of
|
||||
* React rendering, specific context types, or host infrastructure.
|
||||
*
|
||||
* The host (Claude Code) extends this with render methods, richer context
|
||||
* types, and other host-specific features. Host tools structurally satisfy
|
||||
* this interface because they implement all required fields.
|
||||
*
|
||||
* @template Input - Zod schema type for tool input
|
||||
* @template Output - Tool output data type
|
||||
* @template P - Tool progress data type
|
||||
* @template Context - Tool execution context type (host-specific)
|
||||
*/
|
||||
export interface CoreTool<
|
||||
Input extends AnyObject = AnyObject,
|
||||
Output = unknown,
|
||||
P extends ToolProgressData = ToolProgressData,
|
||||
Context = unknown,
|
||||
> {
|
||||
// ── Identity ──
|
||||
readonly name: string
|
||||
aliases?: string[]
|
||||
searchHint?: string
|
||||
|
||||
// ── Schema ──
|
||||
readonly inputSchema: Input
|
||||
readonly inputJSONSchema?: ToolInputJSONSchema
|
||||
outputSchema?: z.ZodType<unknown>
|
||||
|
||||
// ── Execution ──
|
||||
call(
|
||||
args: z.infer<Input>,
|
||||
context: Context,
|
||||
canUseTool: (...args: any[]) => Promise<any>,
|
||||
parentMessage: any,
|
||||
onProgress?: ToolCallProgress<P>,
|
||||
): Promise<ToolResult<Output>>
|
||||
|
||||
// ── Description ──
|
||||
description(
|
||||
input: z.infer<Input>,
|
||||
options: {
|
||||
isNonInteractiveSession: boolean
|
||||
toolPermissionContext: any
|
||||
tools: readonly CoreTool[]
|
||||
},
|
||||
): Promise<string>
|
||||
|
||||
prompt(options: {
|
||||
getToolPermissionContext: () => Promise<any>
|
||||
tools: readonly CoreTool[]
|
||||
agents: any[]
|
||||
allowedAgentTypes?: string[]
|
||||
}): Promise<string>
|
||||
|
||||
// ── Behavioral properties ──
|
||||
isConcurrencySafe(input: z.infer<Input>): boolean
|
||||
isEnabled(): boolean
|
||||
isReadOnly(input: z.infer<Input>): boolean
|
||||
isDestructive?(input: z.infer<Input>): boolean
|
||||
isOpenWorld?(input: z.infer<Input>): boolean
|
||||
interruptBehavior?(): 'cancel' | 'block'
|
||||
requiresUserInteraction?(): boolean
|
||||
|
||||
// ── MCP markers ──
|
||||
isMcp?: boolean
|
||||
isLsp?: boolean
|
||||
readonly shouldDefer?: boolean
|
||||
readonly alwaysLoad?: boolean
|
||||
mcpInfo?: { serverName: string; toolName: string }
|
||||
|
||||
// ── Permissions ──
|
||||
validateInput?(
|
||||
input: z.infer<Input>,
|
||||
context: Context,
|
||||
): Promise<ValidationResult>
|
||||
|
||||
checkPermissions(
|
||||
input: z.infer<Input>,
|
||||
context: Context,
|
||||
): Promise<PermissionResult>
|
||||
|
||||
// ── Utility ──
|
||||
inputsEquivalent?(a: z.infer<Input>, b: z.infer<Input>): boolean
|
||||
getPath?(input: z.infer<Input>): string
|
||||
toAutoClassifierInput(input: z.infer<Input>): unknown
|
||||
backfillObservableInput?(input: Record<string, unknown>): void
|
||||
|
||||
// ── Output ──
|
||||
maxResultSizeChars: number
|
||||
userFacingName(input: Partial<z.infer<Input>> | undefined): string
|
||||
mapToolResultToToolResultBlockParam(
|
||||
content: Output,
|
||||
toolUseID: string,
|
||||
): any
|
||||
|
||||
// ── Optional output helpers ──
|
||||
isResultTruncated?(output: Output): boolean
|
||||
getToolUseSummary?(input: Partial<z.infer<Input>> | undefined): string | null
|
||||
getActivityDescription?(
|
||||
input: Partial<z.infer<Input>> | undefined,
|
||||
): string | null
|
||||
isTransparentWrapper?(): boolean
|
||||
isSearchOrReadCommand?(input: z.infer<Input>): {
|
||||
isSearch: boolean
|
||||
isRead: boolean
|
||||
isList?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool with a generic context type.
|
||||
* This is the default export — hosts can specify their own Context type.
|
||||
*/
|
||||
export type Tool<
|
||||
Input extends AnyObject = AnyObject,
|
||||
Output = unknown,
|
||||
P extends ToolProgressData = ToolProgressData,
|
||||
> = CoreTool<Input, Output, P>
|
||||
|
||||
/**
|
||||
* A collection of tools.
|
||||
*/
|
||||
export type Tools = readonly CoreTool[]
|
||||
16
packages/builtin-tools/package.json
Normal file
16
packages/builtin-tools/package.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "@claude-code-best/builtin-tools",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./tools/*": "./src/tools/*",
|
||||
"./utils": "./src/utils.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@claude-code-best/agent-tools": "workspace:*"
|
||||
}
|
||||
}
|
||||
70
packages/builtin-tools/src/index.ts
Normal file
70
packages/builtin-tools/src/index.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// builtin-tools — All tool implementations for Claude Code
|
||||
// This barrel file re-exports the main tool constants and utilities.
|
||||
// For specific submodules, use deep imports: 'builtin-tools/tools/XTool/XTool.js'
|
||||
|
||||
// =============================================================================
|
||||
// Main tool exports (used by src/tools.ts)
|
||||
// =============================================================================
|
||||
|
||||
// Core tools
|
||||
export { AgentTool } from './tools/AgentTool/AgentTool.js'
|
||||
export { AskUserQuestionTool } from './tools/AskUserQuestionTool/AskUserQuestionTool.js'
|
||||
export { BashTool } from './tools/BashTool/BashTool.js'
|
||||
export { BriefTool } from './tools/BriefTool/BriefTool.js'
|
||||
export { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
|
||||
export { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
|
||||
export { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
|
||||
export { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||
export { ExitWorktreeTool } from './tools/ExitWorktreeTool/ExitWorktreeTool.js'
|
||||
export { FileEditTool } from './tools/FileEditTool/FileEditTool.js'
|
||||
export { FileReadTool } from './tools/FileReadTool/FileReadTool.js'
|
||||
export { FileWriteTool } from './tools/FileWriteTool/FileWriteTool.js'
|
||||
export { GlobTool } from './tools/GlobTool/GlobTool.js'
|
||||
export { GrepTool } from './tools/GrepTool/GrepTool.js'
|
||||
export { LSPTool } from './tools/LSPTool/LSPTool.js'
|
||||
export { ListMcpResourcesTool } from './tools/ListMcpResourcesTool/ListMcpResourcesTool.js'
|
||||
export { ReadMcpResourceTool } from './tools/ReadMcpResourceTool/ReadMcpResourceTool.js'
|
||||
export { NotebookEditTool } from './tools/NotebookEditTool/NotebookEditTool.js'
|
||||
export { SkillTool } from './tools/SkillTool/SkillTool.js'
|
||||
export { TaskOutputTool } from './tools/TaskOutputTool/TaskOutputTool.js'
|
||||
export { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
|
||||
export { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
|
||||
export { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
|
||||
export { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
|
||||
export { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
|
||||
export { WebSearchTool } from './tools/WebSearchTool/WebSearchTool.js'
|
||||
export { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js'
|
||||
|
||||
// Feature-gated tools
|
||||
export { OVERFLOW_TEST_TOOL_NAME } from './tools/OverflowTestTool/OverflowTestTool.js'
|
||||
export { CtxInspectTool } from './tools/CtxInspectTool/CtxInspectTool.js'
|
||||
export { ListPeersTool } from './tools/ListPeersTool/ListPeersTool.js'
|
||||
export { MonitorTool } from './tools/MonitorTool/MonitorTool.js'
|
||||
export { PowerShellTool } from './tools/PowerShellTool/PowerShellTool.js'
|
||||
export { PushNotificationTool } from './tools/PushNotificationTool/PushNotificationTool.js'
|
||||
export { REPLTool } from './tools/REPLTool/REPLTool.js'
|
||||
export { RemoteTriggerTool } from './tools/RemoteTriggerTool/RemoteTriggerTool.js'
|
||||
export { ReviewArtifactTool } from './tools/ReviewArtifactTool/ReviewArtifactTool.js'
|
||||
export { CronCreateTool } from './tools/ScheduleCronTool/CronCreateTool.js'
|
||||
export { CronDeleteTool } from './tools/ScheduleCronTool/CronDeleteTool.js'
|
||||
export { CronListTool } from './tools/ScheduleCronTool/CronListTool.js'
|
||||
export { SendMessageTool } from './tools/SendMessageTool/SendMessageTool.js'
|
||||
export { SendUserFileTool } from './tools/SendUserFileTool/SendUserFileTool.js'
|
||||
export { SleepTool } from './tools/SleepTool/SleepTool.js'
|
||||
export { SnipTool } from './tools/SnipTool/SnipTool.js'
|
||||
export { SubscribePRTool } from './tools/SubscribePRTool/SubscribePRTool.js'
|
||||
export { SuggestBackgroundPRTool } from './tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js'
|
||||
export { TeamCreateTool } from './tools/TeamCreateTool/TeamCreateTool.js'
|
||||
export { TeamDeleteTool } from './tools/TeamDeleteTool/TeamDeleteTool.js'
|
||||
export { TerminalCaptureTool } from './tools/TerminalCaptureTool/TerminalCaptureTool.js'
|
||||
export { VerifyPlanExecutionTool } from './tools/VerifyPlanExecutionTool/VerifyPlanExecutionTool.js'
|
||||
export { WebBrowserTool } from './tools/WebBrowserTool/WebBrowserTool.js'
|
||||
export { WorkflowTool } from './tools/WorkflowTool/WorkflowTool.js'
|
||||
export { initBundledWorkflows } from './tools/WorkflowTool/bundled/index.js'
|
||||
export { getWorkflowCommands } from './tools/WorkflowTool/createWorkflowCommand.js'
|
||||
|
||||
// Constants
|
||||
export { SYNTHETIC_OUTPUT_TOOL_NAME, createSyntheticOutputTool } from './tools/SyntheticOutputTool/SyntheticOutputTool.js'
|
||||
|
||||
// Shared utilities
|
||||
export { tagMessagesWithToolUseID, getToolUseIDFromParentMessage } from './tools/utils.js'
|
||||
1836
packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx
Normal file
1836
packages/builtin-tools/src/tools/AgentTool/AgentTool.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1138
packages/builtin-tools/src/tools/AgentTool/UI.tsx
Normal file
1138
packages/builtin-tools/src/tools/AgentTool/UI.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock heavy deps
|
||||
mock.module("src/utils/model/agent.js", () => ({
|
||||
getDefaultSubagentModel: () => undefined,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/settings/constants.js", () => ({
|
||||
getSourceDisplayName: (source: string) => source,
|
||||
}));
|
||||
|
||||
const {
|
||||
resolveAgentOverrides,
|
||||
compareAgentsByName,
|
||||
AGENT_SOURCE_GROUPS,
|
||||
} = await import("../agentDisplay");
|
||||
|
||||
function makeAgent(agentType: string, source: string): any {
|
||||
return { agentType, source, name: agentType };
|
||||
}
|
||||
|
||||
describe("resolveAgentOverrides", () => {
|
||||
test("marks no overrides when all agents active", () => {
|
||||
const agents = [makeAgent("builder", "userSettings")];
|
||||
const result = resolveAgentOverrides(agents, agents);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].overriddenBy).toBeUndefined();
|
||||
});
|
||||
|
||||
test("marks inactive agent as overridden", () => {
|
||||
const allAgents = [
|
||||
makeAgent("builder", "projectSettings"),
|
||||
makeAgent("builder", "userSettings"),
|
||||
];
|
||||
const activeAgents = [makeAgent("builder", "userSettings")];
|
||||
const result = resolveAgentOverrides(allAgents, activeAgents);
|
||||
const projectAgent = result.find(
|
||||
(a: any) => a.source === "projectSettings",
|
||||
);
|
||||
expect(projectAgent?.overriddenBy).toBe("userSettings");
|
||||
});
|
||||
|
||||
test("overriddenBy shows the overriding agent source", () => {
|
||||
const allAgents = [makeAgent("tester", "localSettings")];
|
||||
const activeAgents = [makeAgent("tester", "policySettings")];
|
||||
const result = resolveAgentOverrides(allAgents, activeAgents);
|
||||
expect(result[0].overriddenBy).toBe("policySettings");
|
||||
});
|
||||
|
||||
test("deduplicates agents by (agentType, source)", () => {
|
||||
const agents = [
|
||||
makeAgent("builder", "userSettings"),
|
||||
makeAgent("builder", "userSettings"), // duplicate
|
||||
];
|
||||
const result = resolveAgentOverrides(agents, agents.slice(0, 1));
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("preserves agent definition properties", () => {
|
||||
const agents = [{ agentType: "a", source: "userSettings", name: "Agent A" }] as any[];
|
||||
const result = resolveAgentOverrides(agents, agents);
|
||||
expect((result[0] as any).name).toBe("Agent A");
|
||||
expect(result[0].agentType).toBe("a");
|
||||
});
|
||||
|
||||
test("handles empty arrays", () => {
|
||||
expect(resolveAgentOverrides([], [])).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles agent from git worktree (duplicate detection)", () => {
|
||||
const agents = [
|
||||
makeAgent("builder", "projectSettings"),
|
||||
makeAgent("builder", "projectSettings"),
|
||||
makeAgent("builder", "localSettings"),
|
||||
];
|
||||
const result = resolveAgentOverrides(agents, agents.slice(0, 1));
|
||||
// Deduped: projectSettings appears once, localSettings once
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compareAgentsByName", () => {
|
||||
test("sorts alphabetically ascending", () => {
|
||||
const a = makeAgent("alpha", "userSettings");
|
||||
const b = makeAgent("beta", "userSettings");
|
||||
expect(compareAgentsByName(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("returns negative when a.name < b.name", () => {
|
||||
const a = makeAgent("a", "s");
|
||||
const b = makeAgent("b", "s");
|
||||
expect(compareAgentsByName(a, b)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test("returns positive when a.name > b.name", () => {
|
||||
const a = makeAgent("z", "s");
|
||||
const b = makeAgent("a", "s");
|
||||
expect(compareAgentsByName(a, b)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("returns 0 for same name", () => {
|
||||
const a = makeAgent("same", "s");
|
||||
const b = makeAgent("same", "s");
|
||||
expect(compareAgentsByName(a, b)).toBe(0);
|
||||
});
|
||||
|
||||
test("is case-insensitive (sensitivity: base)", () => {
|
||||
const a = makeAgent("Alpha", "s");
|
||||
const b = makeAgent("alpha", "s");
|
||||
expect(compareAgentsByName(a, b)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AGENT_SOURCE_GROUPS", () => {
|
||||
test("contains expected source groups in order", () => {
|
||||
expect(AGENT_SOURCE_GROUPS).toHaveLength(7);
|
||||
expect(AGENT_SOURCE_GROUPS[0]).toEqual({
|
||||
label: "User agents",
|
||||
source: "userSettings",
|
||||
});
|
||||
expect(AGENT_SOURCE_GROUPS[6]).toEqual({
|
||||
label: "Built-in agents",
|
||||
source: "built-in",
|
||||
});
|
||||
});
|
||||
|
||||
test("has unique labels", () => {
|
||||
const labels = AGENT_SOURCE_GROUPS.map((g) => g.label);
|
||||
expect(new Set(labels).size).toBe(labels.length);
|
||||
});
|
||||
|
||||
test("has unique sources", () => {
|
||||
const sources = AGENT_SOURCE_GROUPS.map((g) => g.source);
|
||||
expect(new Set(sources).size).toBe(sources.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// ─── Mocks for agentToolUtils.ts dependencies ───
|
||||
// Only mock modules that are truly unavailable or cause side effects.
|
||||
// Do NOT mock common/shared modules (zod/v4, bootstrap/state, etc.) to avoid
|
||||
// corrupting the module cache for other test files in the same Bun process.
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
mock.module("bun:bundle", () => ({ feature: () => false }));
|
||||
|
||||
mock.module("src/constants/tools.js", () => ({
|
||||
ALL_AGENT_DISALLOWED_TOOLS: new Set(),
|
||||
ASYNC_AGENT_ALLOWED_TOOLS: new Set(),
|
||||
CUSTOM_AGENT_DISALLOWED_TOOLS: new Set(),
|
||||
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS: new Set(),
|
||||
}));
|
||||
|
||||
mock.module("src/services/AgentSummary/agentSummary.js", () => ({
|
||||
startAgentSummarization: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/services/analytics/index.js", () => ({
|
||||
logEvent: noop,
|
||||
logEventAsync: async () => {},
|
||||
stripProtoFields: (v: any) => v,
|
||||
attachAnalyticsSink: noop,
|
||||
_resetForTesting: noop,
|
||||
AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined,
|
||||
}));
|
||||
|
||||
mock.module("src/services/api/dumpPrompts.js", () => ({
|
||||
clearDumpState: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/Tool.js", () => ({
|
||||
toolMatchesName: () => false,
|
||||
findToolByName: noop,
|
||||
}));
|
||||
|
||||
// messages.ts is complex - provide stubs for all named exports
|
||||
mock.module("src/utils/messages.ts", () => ({
|
||||
extractTextContent: (content: any[]) =>
|
||||
content?.filter?.((b: any) => b.type === "text")?.map?.((b: any) => b.text)?.join("") ?? "",
|
||||
getLastAssistantMessage: () => null,
|
||||
SYNTHETIC_MESSAGES: new Set(),
|
||||
INTERRUPT_MESSAGE: "",
|
||||
INTERRUPT_MESSAGE_FOR_TOOL_USE: "",
|
||||
CANCEL_MESSAGE: "",
|
||||
REJECT_MESSAGE: "",
|
||||
REJECT_MESSAGE_WITH_REASON_PREFIX: "",
|
||||
SUBAGENT_REJECT_MESSAGE: "",
|
||||
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: "",
|
||||
PLAN_REJECTION_PREFIX: "",
|
||||
DENIAL_WORKAROUND_GUIDANCE: "",
|
||||
NO_RESPONSE_REQUESTED: "",
|
||||
SYNTHETIC_TOOL_RESULT_PLACEHOLDER: "",
|
||||
SYNTHETIC_MODEL: "",
|
||||
AUTO_REJECT_MESSAGE: noop,
|
||||
DONT_ASK_REJECT_MESSAGE: noop,
|
||||
withMemoryCorrectionHint: (s: string) => s,
|
||||
deriveShortMessageId: () => "",
|
||||
isClassifierDenial: () => false,
|
||||
buildYoloRejectionMessage: () => "",
|
||||
buildClassifierUnavailableMessage: () => "",
|
||||
isEmptyMessageText: () => true,
|
||||
createAssistantMessage: noop,
|
||||
createAssistantAPIErrorMessage: noop,
|
||||
createUserMessage: noop,
|
||||
prepareUserContent: noop,
|
||||
createUserInterruptionMessage: noop,
|
||||
createSyntheticUserCaveatMessage: noop,
|
||||
formatCommandInputTags: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({
|
||||
completeAgentTask: noop,
|
||||
createActivityDescriptionResolver: () => ({}),
|
||||
createProgressTracker: () => ({}),
|
||||
enqueueAgentNotification: noop,
|
||||
failAgentTask: noop,
|
||||
getProgressUpdate: () => ({ tokenCount: 0, toolUseCount: 0 }),
|
||||
getTokenCountFromTracker: () => 0,
|
||||
isLocalAgentTask: () => false,
|
||||
killAsyncAgent: noop,
|
||||
updateAgentProgress: noop,
|
||||
updateProgressFromMessage: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/debug.js", () => ({
|
||||
getMinDebugLogLevel: () => "warn",
|
||||
isDebugMode: () => false,
|
||||
enableDebugLogging: () => false,
|
||||
getDebugFilter: () => null,
|
||||
isDebugToStdErr: () => false,
|
||||
getDebugFilePath: () => null,
|
||||
setHasFormattedOutput: noop,
|
||||
getHasFormattedOutput: () => false,
|
||||
flushDebugLogs: async () => {},
|
||||
logForDebugging: noop,
|
||||
getDebugLogPath: () => "",
|
||||
logAntError: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/errors.js", () => ({
|
||||
ClaudeError: class extends Error {},
|
||||
MalformedCommandError: class extends Error {},
|
||||
AbortError: class extends Error {},
|
||||
ConfigParseError: class extends Error {},
|
||||
ShellError: class extends Error {},
|
||||
TeleportOperationError: class extends Error {},
|
||||
TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: class extends Error {},
|
||||
isAbortError: () => false,
|
||||
hasExactErrorMessage: () => false,
|
||||
toError: (e: any) => e instanceof Error ? e : new Error(String(e)),
|
||||
errorMessage: (e: any) => String(e),
|
||||
getErrnoCode: () => undefined,
|
||||
isENOENT: () => false,
|
||||
getErrnoPath: () => undefined,
|
||||
shortErrorStack: () => "",
|
||||
isFsInaccessible: () => false,
|
||||
classifyAxiosError: () => ({ category: "unknown" }),
|
||||
}));
|
||||
|
||||
mock.module("src/utils/forkedAgent.js", () => ({}));
|
||||
|
||||
mock.module("src/utils/permissions/yoloClassifier.js", () => ({
|
||||
buildTranscriptForClassifier: () => "",
|
||||
classifyYoloAction: () => null,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/task/sdkProgress.js", () => ({
|
||||
emitTaskProgress: noop,
|
||||
}));
|
||||
|
||||
mock.module("src/utils/tokens.js", () => ({
|
||||
getTokenCountFromUsage: () => 0,
|
||||
}));
|
||||
|
||||
mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({
|
||||
EXIT_PLAN_MODE_V2_TOOL_NAME: "exit_plan_mode",
|
||||
}));
|
||||
|
||||
mock.module("src/tools/AgentTool/constants.js", () => ({
|
||||
AGENT_TOOL_NAME: "agent",
|
||||
LEGACY_AGENT_TOOL_NAME: "task",
|
||||
}));
|
||||
|
||||
mock.module("src/tools/AgentTool/loadAgentsDir.js", () => ({}));
|
||||
|
||||
mock.module("src/state/AppState.js", () => ({}));
|
||||
|
||||
mock.module("src/types/ids.js", () => ({
|
||||
asAgentId: (id: string) => id,
|
||||
}));
|
||||
|
||||
// Break circular dep
|
||||
mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({
|
||||
AgentTool: {},
|
||||
inputSchema: {},
|
||||
outputSchema: {},
|
||||
default: {},
|
||||
}));
|
||||
|
||||
const {
|
||||
countToolUses,
|
||||
getLastToolUseName,
|
||||
} = await import("../agentToolUtils");
|
||||
|
||||
function makeAssistantMessage(content: any[]): any {
|
||||
return { type: "assistant", message: { content } };
|
||||
}
|
||||
|
||||
function makeUserMessage(text: string): any {
|
||||
return { type: "user", message: { content: text } };
|
||||
}
|
||||
|
||||
describe("countToolUses", () => {
|
||||
test("counts tool_use blocks in messages", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "text", text: "hello" },
|
||||
]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(1);
|
||||
});
|
||||
|
||||
test("returns 0 for messages without tool_use", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([{ type: "text", text: "hello" }]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(0);
|
||||
});
|
||||
|
||||
test("returns 0 for empty array", () => {
|
||||
expect(countToolUses([])).toBe(0);
|
||||
});
|
||||
|
||||
test("counts multiple tool_use blocks across messages", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([{ type: "tool_use", name: "Read" }]),
|
||||
makeUserMessage("ok"),
|
||||
makeAssistantMessage([{ type: "tool_use", name: "Write" }]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(2);
|
||||
});
|
||||
|
||||
test("counts tool_use in single message with multiple blocks", () => {
|
||||
const messages = [
|
||||
makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "tool_use", name: "Grep" },
|
||||
{ type: "tool_use", name: "Write" },
|
||||
]),
|
||||
];
|
||||
expect(countToolUses(messages)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLastToolUseName", () => {
|
||||
test("returns last tool name from assistant message", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "tool_use", name: "Write" },
|
||||
]);
|
||||
expect(getLastToolUseName(msg)).toBe("Write");
|
||||
});
|
||||
|
||||
test("returns undefined for message without tool_use", () => {
|
||||
const msg = makeAssistantMessage([{ type: "text", text: "hello" }]);
|
||||
expect(getLastToolUseName(msg)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("returns the last tool when multiple tool_uses present", () => {
|
||||
const msg = makeAssistantMessage([
|
||||
{ type: "tool_use", name: "Read" },
|
||||
{ type: "tool_use", name: "Grep" },
|
||||
{ type: "tool_use", name: "Edit" },
|
||||
]);
|
||||
expect(getLastToolUseName(msg)).toBe("Edit");
|
||||
});
|
||||
|
||||
test("returns undefined for non-assistant message", () => {
|
||||
const msg = makeUserMessage("hello");
|
||||
expect(getLastToolUseName(msg)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("handles message with null content", () => {
|
||||
const msg = { type: "assistant", message: { content: null } } as any;
|
||||
expect(getLastToolUseName(msg)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { getAgentColorMap } from 'src/bootstrap/state.js'
|
||||
import type { Theme } from 'src/utils/theme.js'
|
||||
|
||||
export type AgentColorName =
|
||||
| 'red'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'yellow'
|
||||
| 'purple'
|
||||
| 'orange'
|
||||
| 'pink'
|
||||
| 'cyan'
|
||||
|
||||
export const AGENT_COLORS: readonly AgentColorName[] = [
|
||||
'red',
|
||||
'blue',
|
||||
'green',
|
||||
'yellow',
|
||||
'purple',
|
||||
'orange',
|
||||
'pink',
|
||||
'cyan',
|
||||
] as const
|
||||
|
||||
export const AGENT_COLOR_TO_THEME_COLOR = {
|
||||
red: 'red_FOR_SUBAGENTS_ONLY',
|
||||
blue: 'blue_FOR_SUBAGENTS_ONLY',
|
||||
green: 'green_FOR_SUBAGENTS_ONLY',
|
||||
yellow: 'yellow_FOR_SUBAGENTS_ONLY',
|
||||
purple: 'purple_FOR_SUBAGENTS_ONLY',
|
||||
orange: 'orange_FOR_SUBAGENTS_ONLY',
|
||||
pink: 'pink_FOR_SUBAGENTS_ONLY',
|
||||
cyan: 'cyan_FOR_SUBAGENTS_ONLY',
|
||||
} as const satisfies Record<AgentColorName, keyof Theme>
|
||||
|
||||
export function getAgentColor(agentType: string): keyof Theme | undefined {
|
||||
if (agentType === 'general-purpose') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const agentColorMap = getAgentColorMap()
|
||||
|
||||
// Check if color already assigned
|
||||
const existingColor = agentColorMap.get(agentType)
|
||||
if (existingColor && AGENT_COLORS.includes(existingColor)) {
|
||||
return AGENT_COLOR_TO_THEME_COLOR[existingColor]
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function setAgentColor(
|
||||
agentType: string,
|
||||
color: AgentColorName | undefined,
|
||||
): void {
|
||||
const agentColorMap = getAgentColorMap()
|
||||
|
||||
if (!color) {
|
||||
agentColorMap.delete(agentType)
|
||||
return
|
||||
}
|
||||
|
||||
if (AGENT_COLORS.includes(color)) {
|
||||
agentColorMap.set(agentType, color)
|
||||
}
|
||||
}
|
||||
104
packages/builtin-tools/src/tools/AgentTool/agentDisplay.ts
Normal file
104
packages/builtin-tools/src/tools/AgentTool/agentDisplay.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Shared utilities for displaying agent information.
|
||||
* Used by both the CLI `claude agents` handler and the interactive `/agents` command.
|
||||
*/
|
||||
|
||||
import { getDefaultSubagentModel } from 'src/utils/model/agent.js'
|
||||
import {
|
||||
getSourceDisplayName,
|
||||
type SettingSource,
|
||||
} from 'src/utils/settings/constants.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
type AgentSource = SettingSource | 'built-in' | 'plugin'
|
||||
|
||||
export type AgentSourceGroup = {
|
||||
label: string
|
||||
source: AgentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered list of agent source groups for display.
|
||||
* Both the CLI and interactive UI should use this to ensure consistent ordering.
|
||||
*/
|
||||
export const AGENT_SOURCE_GROUPS: AgentSourceGroup[] = [
|
||||
{ label: 'User agents', source: 'userSettings' },
|
||||
{ label: 'Project agents', source: 'projectSettings' },
|
||||
{ label: 'Local agents', source: 'localSettings' },
|
||||
{ label: 'Managed agents', source: 'policySettings' },
|
||||
{ label: 'Plugin agents', source: 'plugin' },
|
||||
{ label: 'CLI arg agents', source: 'flagSettings' },
|
||||
{ label: 'Built-in agents', source: 'built-in' },
|
||||
]
|
||||
|
||||
export type ResolvedAgent = AgentDefinition & {
|
||||
overriddenBy?: AgentSource
|
||||
}
|
||||
|
||||
/**
|
||||
* Annotate agents with override information by comparing against the active
|
||||
* (winning) agent list. An agent is "overridden" when another agent with the
|
||||
* same type from a higher-priority source takes precedence.
|
||||
*
|
||||
* Also deduplicates by (agentType, source) to handle git worktree duplicates
|
||||
* where the same agent file is loaded from both the worktree and main repo.
|
||||
*/
|
||||
export function resolveAgentOverrides(
|
||||
allAgents: AgentDefinition[],
|
||||
activeAgents: AgentDefinition[],
|
||||
): ResolvedAgent[] {
|
||||
const activeMap = new Map<string, AgentDefinition>()
|
||||
for (const agent of activeAgents) {
|
||||
activeMap.set(agent.agentType, agent)
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
const resolved: ResolvedAgent[] = []
|
||||
|
||||
// Iterate allAgents, annotating each with override info from activeAgents.
|
||||
// Deduplicate by (agentType, source) to handle git worktree duplicates.
|
||||
for (const agent of allAgents) {
|
||||
const key = `${agent.agentType}:${agent.source}`
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
|
||||
const active = activeMap.get(agent.agentType)
|
||||
const overriddenBy =
|
||||
active && active.source !== agent.source ? active.source : undefined
|
||||
resolved.push({ ...agent, overriddenBy })
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the display model string for an agent.
|
||||
* Returns the model alias or 'inherit' for display purposes.
|
||||
*/
|
||||
export function resolveAgentModelDisplay(
|
||||
agent: AgentDefinition,
|
||||
): string | undefined {
|
||||
const model = agent.model || getDefaultSubagentModel()
|
||||
if (!model) return undefined
|
||||
return model === 'inherit' ? 'inherit' : model
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a human-readable label for the source that overrides an agent.
|
||||
* Returns lowercase, e.g. "user", "project", "managed".
|
||||
*/
|
||||
export function getOverrideSourceLabel(source: AgentSource): string {
|
||||
return getSourceDisplayName(source).toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare agents alphabetically by name (case-insensitive).
|
||||
*/
|
||||
export function compareAgentsByName(
|
||||
a: AgentDefinition,
|
||||
b: AgentDefinition,
|
||||
): number {
|
||||
return a.agentType.localeCompare(b.agentType, undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
}
|
||||
177
packages/builtin-tools/src/tools/AgentTool/agentMemory.ts
Normal file
177
packages/builtin-tools/src/tools/AgentTool/agentMemory.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import { join, normalize, sep } from 'path'
|
||||
import { getProjectRoot } from 'src/bootstrap/state.js'
|
||||
import {
|
||||
buildMemoryPrompt,
|
||||
ensureMemoryDirExists,
|
||||
} from 'src/memdir/memdir.js'
|
||||
import { getMemoryBaseDir } from 'src/memdir/paths.js'
|
||||
import { getCwd } from 'src/utils/cwd.js'
|
||||
import { findCanonicalGitRoot } from 'src/utils/git.js'
|
||||
import { sanitizePath } from 'src/utils/path.js'
|
||||
|
||||
// Persistent agent memory scope: 'user' (~/.claude/agent-memory/), 'project' (.claude/agent-memory/), or 'local' (.claude/agent-memory-local/)
|
||||
export type AgentMemoryScope = 'user' | 'project' | 'local'
|
||||
|
||||
/**
|
||||
* Sanitize an agent type name for use as a directory name.
|
||||
* Replaces colons (invalid on Windows, used in plugin-namespaced agent
|
||||
* types like "my-plugin:my-agent") with dashes.
|
||||
*/
|
||||
function sanitizeAgentTypeForPath(agentType: string): string {
|
||||
return agentType.replace(/:/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local agent memory directory, which is project-specific and not checked into VCS.
|
||||
* When CLAUDE_CODE_REMOTE_MEMORY_DIR is set, persists to the mount with project namespacing.
|
||||
* Otherwise, uses <cwd>/.claude/agent-memory-local/<agentType>/.
|
||||
*/
|
||||
function getLocalAgentMemoryDir(dirName: string): string {
|
||||
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
|
||||
return (
|
||||
join(
|
||||
process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR,
|
||||
'projects',
|
||||
sanitizePath(
|
||||
findCanonicalGitRoot(getProjectRoot()) ?? getProjectRoot(),
|
||||
),
|
||||
'agent-memory-local',
|
||||
dirName,
|
||||
) + sep
|
||||
)
|
||||
}
|
||||
return join(getCwd(), '.claude', 'agent-memory-local', dirName) + sep
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the agent memory directory for a given agent type and scope.
|
||||
* - 'user' scope: <memoryBase>/agent-memory/<agentType>/
|
||||
* - 'project' scope: <cwd>/.claude/agent-memory/<agentType>/
|
||||
* - 'local' scope: see getLocalAgentMemoryDir()
|
||||
*/
|
||||
export function getAgentMemoryDir(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): string {
|
||||
const dirName = sanitizeAgentTypeForPath(agentType)
|
||||
switch (scope) {
|
||||
case 'project':
|
||||
return join(getCwd(), '.claude', 'agent-memory', dirName) + sep
|
||||
case 'local':
|
||||
return getLocalAgentMemoryDir(dirName)
|
||||
case 'user':
|
||||
return join(getMemoryBaseDir(), 'agent-memory', dirName) + sep
|
||||
}
|
||||
}
|
||||
|
||||
// Check if file is within an agent memory directory (any scope).
|
||||
export function isAgentMemoryPath(absolutePath: string): boolean {
|
||||
// SECURITY: Normalize to prevent path traversal bypasses via .. segments
|
||||
const normalizedPath = normalize(absolutePath)
|
||||
const memoryBase = getMemoryBaseDir()
|
||||
|
||||
// User scope: check memory base (may be custom dir or config home)
|
||||
if (normalizedPath.startsWith(join(memoryBase, 'agent-memory') + sep)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Project scope: always cwd-based (not redirected)
|
||||
if (
|
||||
normalizedPath.startsWith(join(getCwd(), '.claude', 'agent-memory') + sep)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Local scope: persisted to mount when CLAUDE_CODE_REMOTE_MEMORY_DIR is set, otherwise cwd-based
|
||||
if (process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR) {
|
||||
if (
|
||||
normalizedPath.includes(sep + 'agent-memory-local' + sep) &&
|
||||
normalizedPath.startsWith(
|
||||
join(process.env.CLAUDE_CODE_REMOTE_MEMORY_DIR, 'projects') + sep,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
} else if (
|
||||
normalizedPath.startsWith(
|
||||
join(getCwd(), '.claude', 'agent-memory-local') + sep,
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the agent memory file path for a given agent type and scope.
|
||||
*/
|
||||
export function getAgentMemoryEntrypoint(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): string {
|
||||
return join(getAgentMemoryDir(agentType, scope), 'MEMORY.md')
|
||||
}
|
||||
|
||||
export function getMemoryScopeDisplay(
|
||||
memory: AgentMemoryScope | undefined,
|
||||
): string {
|
||||
switch (memory) {
|
||||
case 'user':
|
||||
return `User (${join(getMemoryBaseDir(), 'agent-memory')}/)`
|
||||
case 'project':
|
||||
return 'Project (.claude/agent-memory/)'
|
||||
case 'local':
|
||||
return `Local (${getLocalAgentMemoryDir('...')})`
|
||||
default:
|
||||
return 'None'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load persistent memory for an agent with memory enabled.
|
||||
* Creates the memory directory if needed and returns a prompt with memory contents.
|
||||
*
|
||||
* @param agentType The agent's type name (used as directory name)
|
||||
* @param scope 'user' for ~/.claude/agent-memory/ or 'project' for .claude/agent-memory/
|
||||
*/
|
||||
export function loadAgentMemoryPrompt(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): string {
|
||||
let scopeNote: string
|
||||
switch (scope) {
|
||||
case 'user':
|
||||
scopeNote =
|
||||
'- Since this memory is user-scope, keep learnings general since they apply across all projects'
|
||||
break
|
||||
case 'project':
|
||||
scopeNote =
|
||||
'- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project'
|
||||
break
|
||||
case 'local':
|
||||
scopeNote =
|
||||
'- Since this memory is local-scope (not checked into version control), tailor your memories to this project and machine'
|
||||
break
|
||||
}
|
||||
|
||||
const memoryDir = getAgentMemoryDir(agentType, scope)
|
||||
|
||||
// Fire-and-forget: this runs at agent-spawn time inside a sync
|
||||
// getSystemPrompt() callback (called from React render in AgentDetail.tsx,
|
||||
// so it cannot be async). The spawned agent won't try to Write until after
|
||||
// a full API round-trip, by which time mkdir will have completed. Even if
|
||||
// it hasn't, FileWriteTool does its own mkdir of the parent directory.
|
||||
void ensureMemoryDirExists(memoryDir)
|
||||
|
||||
const coworkExtraGuidelines =
|
||||
process.env.CLAUDE_COWORK_MEMORY_EXTRA_GUIDELINES
|
||||
return buildMemoryPrompt({
|
||||
displayName: 'Persistent Agent Memory',
|
||||
memoryDir,
|
||||
extraGuidelines:
|
||||
coworkExtraGuidelines && coworkExtraGuidelines.trim().length > 0
|
||||
? [scopeNote, coworkExtraGuidelines]
|
||||
: [scopeNote],
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { z } from 'zod/v4'
|
||||
import { getCwd } from 'src/utils/cwd.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { jsonParse, jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { type AgentMemoryScope, getAgentMemoryDir } from './agentMemory.js'
|
||||
|
||||
const SNAPSHOT_BASE = 'agent-memory-snapshots'
|
||||
const SNAPSHOT_JSON = 'snapshot.json'
|
||||
const SYNCED_JSON = '.snapshot-synced.json'
|
||||
|
||||
const snapshotMetaSchema = lazySchema(() =>
|
||||
z.object({
|
||||
updatedAt: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
|
||||
const syncedMetaSchema = lazySchema(() =>
|
||||
z.object({
|
||||
syncedFrom: z.string().min(1),
|
||||
}),
|
||||
)
|
||||
type SyncedMeta = z.infer<ReturnType<typeof syncedMetaSchema>>
|
||||
|
||||
/**
|
||||
* Returns the path to the snapshot directory for an agent in the current project.
|
||||
* e.g., <cwd>/.claude/agent-memory-snapshots/<agentType>/
|
||||
*/
|
||||
export function getSnapshotDirForAgent(agentType: string): string {
|
||||
return join(getCwd(), '.claude', SNAPSHOT_BASE, agentType)
|
||||
}
|
||||
|
||||
function getSnapshotJsonPath(agentType: string): string {
|
||||
return join(getSnapshotDirForAgent(agentType), SNAPSHOT_JSON)
|
||||
}
|
||||
|
||||
function getSyncedJsonPath(agentType: string, scope: AgentMemoryScope): string {
|
||||
return join(getAgentMemoryDir(agentType, scope), SYNCED_JSON)
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(
|
||||
path: string,
|
||||
schema: z.ZodType<T>,
|
||||
): Promise<T | null> {
|
||||
try {
|
||||
const content = await readFile(path, { encoding: 'utf-8' })
|
||||
const result = schema.safeParse(jsonParse(content))
|
||||
return result.success ? result.data : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function copySnapshotToLocal(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): Promise<void> {
|
||||
const snapshotMemDir = getSnapshotDirForAgent(agentType)
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
|
||||
await mkdir(localMemDir, { recursive: true })
|
||||
|
||||
try {
|
||||
const files = await readdir(snapshotMemDir, { withFileTypes: true })
|
||||
for (const dirent of files) {
|
||||
if (!dirent.isFile() || dirent.name === SNAPSHOT_JSON) continue
|
||||
const content = await readFile(join(snapshotMemDir, dirent.name), {
|
||||
encoding: 'utf-8',
|
||||
})
|
||||
await writeFile(join(localMemDir, dirent.name), content)
|
||||
}
|
||||
} catch (e) {
|
||||
logForDebugging(`Failed to copy snapshot to local agent memory: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSyncedMeta(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
const syncedPath = getSyncedJsonPath(agentType, scope)
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
await mkdir(localMemDir, { recursive: true })
|
||||
const meta: SyncedMeta = { syncedFrom: snapshotTimestamp }
|
||||
try {
|
||||
await writeFile(syncedPath, jsonStringify(meta))
|
||||
} catch (e) {
|
||||
logForDebugging(`Failed to save snapshot sync metadata: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a snapshot exists and whether it's newer than what we last synced.
|
||||
*/
|
||||
export async function checkAgentMemorySnapshot(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
): Promise<{
|
||||
action: 'none' | 'initialize' | 'prompt-update'
|
||||
snapshotTimestamp?: string
|
||||
}> {
|
||||
const snapshotMeta = await readJsonFile(
|
||||
getSnapshotJsonPath(agentType),
|
||||
snapshotMetaSchema(),
|
||||
)
|
||||
|
||||
if (!snapshotMeta) {
|
||||
return { action: 'none' }
|
||||
}
|
||||
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
|
||||
let hasLocalMemory = false
|
||||
try {
|
||||
const dirents = await readdir(localMemDir, { withFileTypes: true })
|
||||
hasLocalMemory = dirents.some(d => d.isFile() && d.name.endsWith('.md'))
|
||||
} catch {
|
||||
// Directory doesn't exist
|
||||
}
|
||||
|
||||
if (!hasLocalMemory) {
|
||||
return { action: 'initialize', snapshotTimestamp: snapshotMeta.updatedAt }
|
||||
}
|
||||
|
||||
const syncedMeta = await readJsonFile(
|
||||
getSyncedJsonPath(agentType, scope),
|
||||
syncedMetaSchema(),
|
||||
)
|
||||
|
||||
if (
|
||||
!syncedMeta ||
|
||||
new Date(snapshotMeta.updatedAt) > new Date(syncedMeta.syncedFrom)
|
||||
) {
|
||||
return {
|
||||
action: 'prompt-update',
|
||||
snapshotTimestamp: snapshotMeta.updatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return { action: 'none' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize local agent memory from a snapshot (first-time setup).
|
||||
*/
|
||||
export async function initializeFromSnapshot(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
logForDebugging(
|
||||
`Initializing agent memory for ${agentType} from project snapshot`,
|
||||
)
|
||||
await copySnapshotToLocal(agentType, scope)
|
||||
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace local agent memory with the snapshot.
|
||||
*/
|
||||
export async function replaceFromSnapshot(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
logForDebugging(
|
||||
`Replacing agent memory for ${agentType} with project snapshot`,
|
||||
)
|
||||
// Remove existing .md files before copying to avoid orphans
|
||||
const localMemDir = getAgentMemoryDir(agentType, scope)
|
||||
try {
|
||||
const existing = await readdir(localMemDir, { withFileTypes: true })
|
||||
for (const dirent of existing) {
|
||||
if (dirent.isFile() && dirent.name.endsWith('.md')) {
|
||||
await unlink(join(localMemDir, dirent.name))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Directory may not exist yet
|
||||
}
|
||||
await copySnapshotToLocal(agentType, scope)
|
||||
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the current snapshot as synced without changing local memory.
|
||||
*/
|
||||
export async function markSnapshotSynced(
|
||||
agentType: string,
|
||||
scope: AgentMemoryScope,
|
||||
snapshotTimestamp: string,
|
||||
): Promise<void> {
|
||||
await saveSyncedMeta(agentType, scope, snapshotTimestamp)
|
||||
}
|
||||
687
packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts
Normal file
687
packages/builtin-tools/src/tools/AgentTool/agentToolUtils.ts
Normal file
@@ -0,0 +1,687 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { z } from 'zod/v4'
|
||||
import { clearInvokedSkillsForAgent } from 'src/bootstrap/state.js'
|
||||
import {
|
||||
ALL_AGENT_DISALLOWED_TOOLS,
|
||||
ASYNC_AGENT_ALLOWED_TOOLS,
|
||||
CUSTOM_AGENT_DISALLOWED_TOOLS,
|
||||
IN_PROCESS_TEAMMATE_ALLOWED_TOOLS,
|
||||
} from 'src/constants/tools.js'
|
||||
import { startAgentSummarization } from 'src/services/AgentSummary/agentSummary.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import { clearDumpState } from 'src/services/api/dumpPrompts.js'
|
||||
import type { AppState } from 'src/state/AppState.js'
|
||||
import type {
|
||||
Tool,
|
||||
ToolPermissionContext,
|
||||
Tools,
|
||||
ToolUseContext,
|
||||
} from 'src/Tool.js'
|
||||
import { toolMatchesName } from 'src/Tool.js'
|
||||
import {
|
||||
completeAgentTask as completeAsyncAgent,
|
||||
createActivityDescriptionResolver,
|
||||
createProgressTracker,
|
||||
enqueueAgentNotification,
|
||||
failAgentTask as failAsyncAgent,
|
||||
getProgressUpdate,
|
||||
getTokenCountFromTracker,
|
||||
isLocalAgentTask,
|
||||
killAsyncAgent,
|
||||
type ProgressTracker,
|
||||
updateAgentProgress as updateAsyncAgentProgress,
|
||||
updateProgressFromMessage,
|
||||
} from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { asAgentId } from 'src/types/ids.js'
|
||||
import type { Message as MessageType, ContentItem } from 'src/types/message.js'
|
||||
import { isAgentSwarmsEnabled } from 'src/utils/agentSwarmsEnabled.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { isInProtectedNamespace } from 'src/utils/envUtils.js'
|
||||
import { AbortError, errorMessage } from 'src/utils/errors.js'
|
||||
import type { CacheSafeParams } from 'src/utils/forkedAgent.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
extractTextContent,
|
||||
getLastAssistantMessage,
|
||||
} from 'src/utils/messages.js'
|
||||
import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'
|
||||
import { permissionRuleValueFromString } from 'src/utils/permissions/permissionRuleParser.js'
|
||||
import {
|
||||
buildTranscriptForClassifier,
|
||||
classifyYoloAction,
|
||||
} from 'src/utils/permissions/yoloClassifier.js'
|
||||
import { emitTaskProgress as emitTaskProgressEvent } from 'src/utils/task/sdkProgress.js'
|
||||
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
|
||||
import { getTokenCountFromUsage } from 'src/utils/tokens.js'
|
||||
import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
|
||||
import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from './constants.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
export type ResolvedAgentTools = {
|
||||
hasWildcard: boolean
|
||||
validTools: string[]
|
||||
invalidTools: string[]
|
||||
resolvedTools: Tools
|
||||
allowedAgentTypes?: string[]
|
||||
}
|
||||
|
||||
export function filterToolsForAgent({
|
||||
tools,
|
||||
isBuiltIn,
|
||||
isAsync = false,
|
||||
permissionMode,
|
||||
}: {
|
||||
tools: Tools
|
||||
isBuiltIn: boolean
|
||||
isAsync?: boolean
|
||||
permissionMode?: PermissionMode
|
||||
}): Tools {
|
||||
return tools.filter(tool => {
|
||||
// Allow MCP tools for all agents
|
||||
if (tool.name.startsWith('mcp__')) {
|
||||
return true
|
||||
}
|
||||
// Allow ExitPlanMode for agents in plan mode (e.g., in-process teammates)
|
||||
// This bypasses both the ALL_AGENT_DISALLOWED_TOOLS and async tool filters
|
||||
if (
|
||||
toolMatchesName(tool, EXIT_PLAN_MODE_V2_TOOL_NAME) &&
|
||||
permissionMode === 'plan'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
|
||||
return false
|
||||
}
|
||||
if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) {
|
||||
return false
|
||||
}
|
||||
if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) {
|
||||
if (isAgentSwarmsEnabled() && isInProcessTeammate()) {
|
||||
// Allow AgentTool for in-process teammates to spawn sync subagents.
|
||||
// Validation in AgentTool.call() prevents background agents and teammate spawning.
|
||||
if (toolMatchesName(tool, AGENT_TOOL_NAME)) {
|
||||
return true
|
||||
}
|
||||
// Allow task tools for in-process teammates to coordinate via shared task list
|
||||
if (IN_PROCESS_TEAMMATE_ALLOWED_TOOLS.has(tool.name)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves and validates agent tools against available tools
|
||||
* Handles wildcard expansion and validation in one place
|
||||
*/
|
||||
export function resolveAgentTools(
|
||||
agentDefinition: Pick<
|
||||
AgentDefinition,
|
||||
'tools' | 'disallowedTools' | 'source' | 'permissionMode'
|
||||
>,
|
||||
availableTools: Tools,
|
||||
isAsync = false,
|
||||
isMainThread = false,
|
||||
): ResolvedAgentTools {
|
||||
const {
|
||||
tools: agentTools,
|
||||
disallowedTools,
|
||||
source,
|
||||
permissionMode,
|
||||
} = agentDefinition
|
||||
// When isMainThread is true, skip filterToolsForAgent entirely — the main
|
||||
// thread's tool pool is already properly assembled by useMergedTools(), so
|
||||
// the sub-agent disallow lists shouldn't apply.
|
||||
const filteredAvailableTools = isMainThread
|
||||
? availableTools
|
||||
: filterToolsForAgent({
|
||||
tools: availableTools,
|
||||
isBuiltIn: source === 'built-in',
|
||||
isAsync,
|
||||
permissionMode,
|
||||
})
|
||||
|
||||
// Create a set of disallowed tool names for quick lookup
|
||||
const disallowedToolSet = new Set(
|
||||
disallowedTools?.map(toolSpec => {
|
||||
const { toolName } = permissionRuleValueFromString(toolSpec)
|
||||
return toolName
|
||||
}) ?? [],
|
||||
)
|
||||
|
||||
// Filter available tools based on disallowed list
|
||||
const allowedAvailableTools = filteredAvailableTools.filter(
|
||||
tool => !disallowedToolSet.has(tool.name),
|
||||
)
|
||||
|
||||
// If tools is undefined or ['*'], allow all tools (after filtering disallowed)
|
||||
const hasWildcard =
|
||||
agentTools === undefined ||
|
||||
(agentTools.length === 1 && agentTools[0] === '*')
|
||||
if (hasWildcard) {
|
||||
return {
|
||||
hasWildcard: true,
|
||||
validTools: [],
|
||||
invalidTools: [],
|
||||
resolvedTools: allowedAvailableTools,
|
||||
}
|
||||
}
|
||||
|
||||
const availableToolMap = new Map<string, Tool>()
|
||||
for (const tool of allowedAvailableTools) {
|
||||
availableToolMap.set(tool.name, tool)
|
||||
}
|
||||
|
||||
const validTools: string[] = []
|
||||
const invalidTools: string[] = []
|
||||
const resolved: Tool[] = []
|
||||
const resolvedToolsSet = new Set<Tool>()
|
||||
let allowedAgentTypes: string[] | undefined
|
||||
|
||||
for (const toolSpec of agentTools) {
|
||||
// Parse the tool spec to extract the base tool name and any permission pattern
|
||||
const { toolName, ruleContent } = permissionRuleValueFromString(toolSpec)
|
||||
|
||||
// Special case: Agent tool carries allowedAgentTypes metadata in its spec
|
||||
if (toolName === AGENT_TOOL_NAME) {
|
||||
if (ruleContent) {
|
||||
// Parse comma-separated agent types: "worker, researcher" → ["worker", "researcher"]
|
||||
allowedAgentTypes = ruleContent.split(',').map(s => s.trim())
|
||||
}
|
||||
// For sub-agents, Agent is excluded by filterToolsForAgent — mark the spec
|
||||
// valid for allowedAgentTypes tracking but skip tool resolution.
|
||||
if (!isMainThread) {
|
||||
validTools.push(toolSpec)
|
||||
continue
|
||||
}
|
||||
// For main thread, filtering was skipped so Agent is in availableToolMap —
|
||||
// fall through to normal resolution below.
|
||||
}
|
||||
|
||||
const tool = availableToolMap.get(toolName)
|
||||
if (tool) {
|
||||
validTools.push(toolSpec)
|
||||
if (!resolvedToolsSet.has(tool)) {
|
||||
resolved.push(tool)
|
||||
resolvedToolsSet.add(tool)
|
||||
}
|
||||
} else {
|
||||
invalidTools.push(toolSpec)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
hasWildcard: false,
|
||||
validTools,
|
||||
invalidTools,
|
||||
resolvedTools: resolved,
|
||||
allowedAgentTypes,
|
||||
}
|
||||
}
|
||||
|
||||
export const agentToolResultSchema = lazySchema(() =>
|
||||
z.object({
|
||||
agentId: z.string(),
|
||||
// Optional: older persisted sessions won't have this (resume replays
|
||||
// results verbatim without re-validation). Used to gate the sync
|
||||
// result trailer — one-shot built-ins skip the SendMessage hint.
|
||||
agentType: z.string().optional(),
|
||||
content: z.array(z.object({ type: z.literal('text'), text: z.string() })),
|
||||
totalToolUseCount: z.number(),
|
||||
totalDurationMs: z.number(),
|
||||
totalTokens: z.number(),
|
||||
usage: z.object({
|
||||
input_tokens: z.number(),
|
||||
output_tokens: z.number(),
|
||||
cache_creation_input_tokens: z.number().nullable(),
|
||||
cache_read_input_tokens: z.number().nullable(),
|
||||
server_tool_use: z
|
||||
.object({
|
||||
web_search_requests: z.number(),
|
||||
web_fetch_requests: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
service_tier: z.enum(['standard', 'priority', 'batch']).nullable(),
|
||||
cache_creation: z
|
||||
.object({
|
||||
ephemeral_1h_input_tokens: z.number(),
|
||||
ephemeral_5m_input_tokens: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
export type AgentToolResult = z.input<ReturnType<typeof agentToolResultSchema>>
|
||||
|
||||
export function countToolUses(messages: MessageType[]): number {
|
||||
let count = 0
|
||||
for (const m of messages) {
|
||||
if (m.type === 'assistant') {
|
||||
const content = m.message?.content as ContentItem[] | undefined
|
||||
for (const block of content ?? []) {
|
||||
if (block.type === 'tool_use') {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
export function finalizeAgentTool(
|
||||
agentMessages: MessageType[],
|
||||
agentId: string,
|
||||
metadata: {
|
||||
prompt: string
|
||||
resolvedAgentModel: string
|
||||
isBuiltInAgent: boolean
|
||||
startTime: number
|
||||
agentType: string
|
||||
isAsync: boolean
|
||||
},
|
||||
): AgentToolResult {
|
||||
const {
|
||||
prompt,
|
||||
resolvedAgentModel,
|
||||
isBuiltInAgent,
|
||||
startTime,
|
||||
agentType,
|
||||
isAsync,
|
||||
} = metadata
|
||||
|
||||
const lastAssistantMessage = getLastAssistantMessage(agentMessages)
|
||||
if (lastAssistantMessage === undefined) {
|
||||
throw new Error('No assistant messages found')
|
||||
}
|
||||
// Extract text content from the agent's response. If the final assistant
|
||||
// message is a pure tool_use block (loop exited mid-turn), fall back to
|
||||
// the most recent assistant message that has text content.
|
||||
let content = (lastAssistantMessage.message?.content as ContentItem[] ?? []).filter(
|
||||
_ => _.type === 'text',
|
||||
)
|
||||
if (content.length === 0) {
|
||||
for (let i = agentMessages.length - 1; i >= 0; i--) {
|
||||
const m = agentMessages[i]!
|
||||
if (m.type !== 'assistant') continue
|
||||
const textBlocks = (m.message?.content as ContentItem[] ?? []).filter(_ => _.type === 'text')
|
||||
if (textBlocks.length > 0) {
|
||||
content = textBlocks
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message?.usage as Parameters<typeof getTokenCountFromUsage>[0])
|
||||
const totalToolUseCount = countToolUses(agentMessages)
|
||||
|
||||
logEvent('tengu_agent_tool_completed', {
|
||||
agent_type:
|
||||
agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
model:
|
||||
resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
prompt_char_count: prompt.length,
|
||||
response_char_count: content.length,
|
||||
assistant_message_count: agentMessages.length,
|
||||
total_tool_uses: totalToolUseCount,
|
||||
duration_ms: Date.now() - startTime,
|
||||
total_tokens: totalTokens,
|
||||
is_built_in_agent: isBuiltInAgent,
|
||||
is_async: isAsync,
|
||||
})
|
||||
|
||||
// Signal to inference that this subagent's cache chain can be evicted.
|
||||
const lastRequestId = lastAssistantMessage.requestId
|
||||
if (lastRequestId) {
|
||||
logEvent('tengu_cache_eviction_hint', {
|
||||
scope:
|
||||
'subagent_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
last_request_id:
|
||||
lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
agentId,
|
||||
agentType,
|
||||
content,
|
||||
totalDurationMs: Date.now() - startTime,
|
||||
totalTokens,
|
||||
totalToolUseCount,
|
||||
usage: lastAssistantMessage.message?.usage as AgentToolResult['usage'],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the name of the last tool_use block in an assistant message,
|
||||
* or undefined if the message is not an assistant message with tool_use.
|
||||
*/
|
||||
export function getLastToolUseName(message: MessageType): string | undefined {
|
||||
if (message.type !== 'assistant') return undefined
|
||||
const block = (message.message?.content as ContentItem[] ?? []).findLast(b => b.type === 'tool_use')
|
||||
return block?.type === 'tool_use' ? block.name : undefined
|
||||
}
|
||||
|
||||
export function emitTaskProgress(
|
||||
tracker: ProgressTracker,
|
||||
taskId: string,
|
||||
toolUseId: string | undefined,
|
||||
description: string,
|
||||
startTime: number,
|
||||
lastToolName: string,
|
||||
): void {
|
||||
const progress = getProgressUpdate(tracker)
|
||||
emitTaskProgressEvent({
|
||||
taskId,
|
||||
toolUseId,
|
||||
description: progress.lastActivity?.activityDescription ?? description,
|
||||
startTime,
|
||||
totalTokens: progress.tokenCount,
|
||||
toolUses: progress.toolUseCount,
|
||||
lastToolName,
|
||||
})
|
||||
}
|
||||
|
||||
export async function classifyHandoffIfNeeded({
|
||||
agentMessages,
|
||||
tools,
|
||||
toolPermissionContext,
|
||||
abortSignal,
|
||||
subagentType,
|
||||
totalToolUseCount,
|
||||
}: {
|
||||
agentMessages: MessageType[]
|
||||
tools: Tools
|
||||
toolPermissionContext: AppState['toolPermissionContext']
|
||||
abortSignal: AbortSignal
|
||||
subagentType: string
|
||||
totalToolUseCount: number
|
||||
}): Promise<string | null> {
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
if (toolPermissionContext.mode !== 'auto') return null
|
||||
|
||||
const agentTranscript = buildTranscriptForClassifier(agentMessages, tools)
|
||||
if (!agentTranscript) return null
|
||||
|
||||
const classifierResult = await classifyYoloAction(
|
||||
agentMessages,
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "Sub-agent has finished and is handing back control to the main agent. Review the sub-agent's work based on the block rules and let the main agent know if any file is dangerous (the main agent will see the reason).",
|
||||
},
|
||||
],
|
||||
},
|
||||
tools,
|
||||
toolPermissionContext as ToolPermissionContext,
|
||||
abortSignal,
|
||||
)
|
||||
|
||||
const handoffDecision = classifierResult.unavailable
|
||||
? 'unavailable'
|
||||
: classifierResult.shouldBlock
|
||||
? 'blocked'
|
||||
: 'allowed'
|
||||
logEvent('tengu_auto_mode_decision', {
|
||||
decision:
|
||||
handoffDecision as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolName:
|
||||
// Use legacy name for analytics continuity across the Task→Agent rename
|
||||
LEGACY_AGENT_TOOL_NAME as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
inProtectedNamespace: isInProtectedNamespace(),
|
||||
classifierModel:
|
||||
classifierResult.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
agentType:
|
||||
subagentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
toolUseCount: totalToolUseCount,
|
||||
isHandoff: true,
|
||||
// For handoff, the relevant agent completion is the subagent's final
|
||||
// assistant message — the last thing the classifier transcript shows
|
||||
// before the handoff review prompt.
|
||||
agentMsgId: getLastAssistantMessage(agentMessages)?.message
|
||||
.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage:
|
||||
classifierResult.stage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage1RequestId:
|
||||
classifierResult.stage1RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage1MsgId:
|
||||
classifierResult.stage1MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage2RequestId:
|
||||
classifierResult.stage2RequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
classifierStage2MsgId:
|
||||
classifierResult.stage2MsgId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
if (classifierResult.shouldBlock) {
|
||||
// When classifier is unavailable, still propagate the sub-agent's
|
||||
// results but with a warning so the parent agent can verify the work.
|
||||
if (classifierResult.unavailable) {
|
||||
logForDebugging(
|
||||
'Handoff classifier unavailable, allowing sub-agent output with warning',
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return `Note: The safety classifier was unavailable when reviewing this sub-agent's work. Please carefully verify the sub-agent's actions and output before acting on them.`
|
||||
}
|
||||
|
||||
logForDebugging(
|
||||
`Handoff classifier flagged sub-agent output: ${classifierResult.reason}`,
|
||||
{ level: 'warn' },
|
||||
)
|
||||
return `SECURITY WARNING: This sub-agent performed actions that may violate security policy. Reason: ${classifierResult.reason}. Review the sub-agent's actions carefully before acting on its output.`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a partial result string from an agent's accumulated messages.
|
||||
* Used when an async agent is killed to preserve what it accomplished.
|
||||
* Returns undefined if no text content is found.
|
||||
*/
|
||||
export function extractPartialResult(
|
||||
messages: MessageType[],
|
||||
): string | undefined {
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
const m = messages[i]!
|
||||
if (m.type !== 'assistant') continue
|
||||
const text = extractTextContent(m.message?.content as ContentItem[] ?? [], '\n')
|
||||
if (text) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
type SetAppState = (f: (prev: AppState) => AppState) => void
|
||||
|
||||
/**
|
||||
* Drives a background agent from spawn to terminal notification.
|
||||
* Shared between AgentTool's async-from-start path and resumeAgentBackground.
|
||||
*/
|
||||
export async function runAsyncAgentLifecycle({
|
||||
taskId,
|
||||
abortController,
|
||||
makeStream,
|
||||
metadata,
|
||||
description,
|
||||
toolUseContext,
|
||||
rootSetAppState,
|
||||
agentIdForCleanup,
|
||||
enableSummarization,
|
||||
getWorktreeResult,
|
||||
}: {
|
||||
taskId: string
|
||||
abortController: AbortController
|
||||
makeStream: (
|
||||
onCacheSafeParams: ((p: CacheSafeParams) => void) | undefined,
|
||||
) => AsyncGenerator<MessageType, void>
|
||||
metadata: Parameters<typeof finalizeAgentTool>[2]
|
||||
description: string
|
||||
toolUseContext: ToolUseContext
|
||||
rootSetAppState: SetAppState
|
||||
agentIdForCleanup: string
|
||||
enableSummarization: boolean
|
||||
getWorktreeResult: () => Promise<{
|
||||
worktreePath?: string
|
||||
worktreeBranch?: string
|
||||
}>
|
||||
}): Promise<void> {
|
||||
let stopSummarization: (() => void) | undefined
|
||||
const agentMessages: MessageType[] = []
|
||||
try {
|
||||
const tracker = createProgressTracker()
|
||||
const resolveActivity = createActivityDescriptionResolver(
|
||||
toolUseContext.options.tools,
|
||||
)
|
||||
const onCacheSafeParams = enableSummarization
|
||||
? (params: CacheSafeParams) => {
|
||||
const { stop } = startAgentSummarization(
|
||||
taskId,
|
||||
asAgentId(taskId),
|
||||
params,
|
||||
rootSetAppState,
|
||||
)
|
||||
stopSummarization = stop
|
||||
}
|
||||
: undefined
|
||||
for await (const message of makeStream(onCacheSafeParams)) {
|
||||
agentMessages.push(message)
|
||||
// Append immediately when UI holds the task (retain). Bootstrap reads
|
||||
// disk in parallel and UUID-merges the prefix — disk-write-before-yield
|
||||
// means live is always a suffix of disk, so merge is order-correct.
|
||||
rootSetAppState(prev => {
|
||||
const t = prev.tasks[taskId]
|
||||
if (!isLocalAgentTask(t) || !t.retain) return prev
|
||||
const base = t.messages ?? []
|
||||
return {
|
||||
...prev,
|
||||
tasks: {
|
||||
...prev.tasks,
|
||||
[taskId]: { ...t, messages: [...base, message] },
|
||||
},
|
||||
}
|
||||
})
|
||||
updateProgressFromMessage(
|
||||
tracker,
|
||||
message,
|
||||
resolveActivity,
|
||||
toolUseContext.options.tools,
|
||||
)
|
||||
updateAsyncAgentProgress(
|
||||
taskId,
|
||||
getProgressUpdate(tracker),
|
||||
rootSetAppState,
|
||||
)
|
||||
const lastToolName = getLastToolUseName(message)
|
||||
if (lastToolName) {
|
||||
emitTaskProgress(
|
||||
tracker,
|
||||
taskId,
|
||||
toolUseContext.toolUseId,
|
||||
description,
|
||||
metadata.startTime,
|
||||
lastToolName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
stopSummarization?.()
|
||||
|
||||
const agentResult = finalizeAgentTool(agentMessages, taskId, metadata)
|
||||
|
||||
// Mark task completed FIRST so TaskOutput(block=true) unblocks
|
||||
// immediately. classifyHandoffIfNeeded (API call) and getWorktreeResult
|
||||
// (git exec) are notification embellishments that can hang — they must
|
||||
// not gate the status transition (gh-20236).
|
||||
completeAsyncAgent(agentResult, rootSetAppState)
|
||||
|
||||
let finalMessage = extractTextContent(agentResult.content, '\n')
|
||||
|
||||
if (feature('TRANSCRIPT_CLASSIFIER')) {
|
||||
const handoffWarning = await classifyHandoffIfNeeded({
|
||||
agentMessages,
|
||||
tools: toolUseContext.options.tools,
|
||||
toolPermissionContext:
|
||||
toolUseContext.getAppState().toolPermissionContext,
|
||||
abortSignal: abortController.signal,
|
||||
subagentType: metadata.agentType,
|
||||
totalToolUseCount: agentResult.totalToolUseCount,
|
||||
})
|
||||
if (handoffWarning) {
|
||||
finalMessage = `${handoffWarning}\n\n${finalMessage}`
|
||||
}
|
||||
}
|
||||
|
||||
const worktreeResult = await getWorktreeResult()
|
||||
|
||||
enqueueAgentNotification({
|
||||
taskId,
|
||||
description,
|
||||
status: 'completed',
|
||||
setAppState: rootSetAppState,
|
||||
finalMessage,
|
||||
usage: {
|
||||
totalTokens: getTokenCountFromTracker(tracker),
|
||||
toolUses: agentResult.totalToolUseCount,
|
||||
durationMs: agentResult.totalDurationMs,
|
||||
},
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
...worktreeResult,
|
||||
})
|
||||
} catch (error) {
|
||||
stopSummarization?.()
|
||||
if (error instanceof AbortError) {
|
||||
// killAsyncAgent is a no-op if TaskStop already set status='killed' —
|
||||
// but only this catch handler has agentMessages, so the notification
|
||||
// must fire unconditionally. Transition status BEFORE worktree cleanup
|
||||
// so TaskOutput unblocks even if git hangs (gh-20236).
|
||||
killAsyncAgent(taskId, rootSetAppState)
|
||||
logEvent('tengu_agent_tool_terminated', {
|
||||
agent_type:
|
||||
metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
model:
|
||||
metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
duration_ms: Date.now() - metadata.startTime,
|
||||
is_async: true,
|
||||
is_built_in_agent: metadata.isBuiltInAgent,
|
||||
reason:
|
||||
'user_kill_async' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
const worktreeResult = await getWorktreeResult()
|
||||
const partialResult = extractPartialResult(agentMessages)
|
||||
enqueueAgentNotification({
|
||||
taskId,
|
||||
description,
|
||||
status: 'killed',
|
||||
setAppState: rootSetAppState,
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
finalMessage: partialResult,
|
||||
...worktreeResult,
|
||||
})
|
||||
return
|
||||
}
|
||||
const msg = errorMessage(error)
|
||||
failAsyncAgent(taskId, msg, rootSetAppState)
|
||||
const worktreeResult = await getWorktreeResult()
|
||||
enqueueAgentNotification({
|
||||
taskId,
|
||||
description,
|
||||
status: 'failed',
|
||||
error: msg,
|
||||
setAppState: rootSetAppState,
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
...worktreeResult,
|
||||
})
|
||||
} finally {
|
||||
clearInvokedSkillsForAgent(agentIdForCleanup)
|
||||
clearDumpState(agentIdForCleanup)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
|
||||
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GrepTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SendMessageTool/constants.js'
|
||||
import { WEB_FETCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WebFetchTool/prompt.js'
|
||||
import { WEB_SEARCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WebSearchTool/prompt.js'
|
||||
import { isUsing3PServices } from 'src/utils/auth.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import type {
|
||||
AgentDefinition,
|
||||
BuiltInAgentDefinition,
|
||||
} from '../loadAgentsDir.js'
|
||||
|
||||
const CLAUDE_CODE_DOCS_MAP_URL =
|
||||
'https://code.claude.com/docs/en/claude_code_docs_map.md'
|
||||
const CDP_DOCS_MAP_URL = 'https://platform.claude.com/llms.txt'
|
||||
|
||||
export const CLAUDE_CODE_GUIDE_AGENT_TYPE = 'claude-code-guide'
|
||||
|
||||
function getClaudeCodeGuideBasePrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find/grep instead.
|
||||
const localSearchHint = hasEmbeddedSearchTools()
|
||||
? `${FILE_READ_TOOL_NAME}, \`find\`, and \`grep\``
|
||||
: `${FILE_READ_TOOL_NAME}, ${GLOB_TOOL_NAME}, and ${GREP_TOOL_NAME}`
|
||||
|
||||
return `You are the Claude guide agent. Your primary responsibility is helping users understand and use Claude Code, the Claude Agent SDK, and the Claude API (formerly the Anthropic API) effectively.
|
||||
|
||||
**Your expertise spans three domains:**
|
||||
|
||||
1. **Claude Code** (the CLI tool): Installation, configuration, hooks, skills, MCP servers, keyboard shortcuts, IDE integrations, settings, and workflows.
|
||||
|
||||
2. **Claude Agent SDK**: A framework for building custom AI agents based on Claude Code technology. Available for Node.js/TypeScript and Python.
|
||||
|
||||
3. **Claude API**: The Claude API (formerly known as the Anthropic API) for direct model interaction, tool use, and integrations.
|
||||
|
||||
**Documentation sources:**
|
||||
|
||||
- **Claude Code docs** (${CLAUDE_CODE_DOCS_MAP_URL}): Fetch this for questions about the Claude Code CLI tool, including:
|
||||
- Installation, setup, and getting started
|
||||
- Hooks (pre/post command execution)
|
||||
- Custom skills
|
||||
- MCP server configuration
|
||||
- IDE integrations (VS Code, JetBrains)
|
||||
- Settings files and configuration
|
||||
- Keyboard shortcuts and hotkeys
|
||||
- Subagents and plugins
|
||||
- Sandboxing and security
|
||||
|
||||
- **Claude Agent SDK docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about building agents with the SDK, including:
|
||||
- SDK overview and getting started (Python and TypeScript)
|
||||
- Agent configuration + custom tools
|
||||
- Session management and permissions
|
||||
- MCP integration in agents
|
||||
- Hosting and deployment
|
||||
- Cost tracking and context management
|
||||
Note: Agent SDK docs are part of the Claude API documentation at the same URL.
|
||||
|
||||
- **Claude API docs** (${CDP_DOCS_MAP_URL}): Fetch this for questions about the Claude API (formerly the Anthropic API), including:
|
||||
- Messages API and streaming
|
||||
- Tool use (function calling) and Anthropic-defined tools (computer use, code execution, web search, text editor, bash, programmatic tool calling, tool search tool, context editing, Files API, structured outputs)
|
||||
- Vision, PDF support, and citations
|
||||
- Extended thinking and structured outputs
|
||||
- MCP connector for remote MCP servers
|
||||
- Cloud provider integrations (Bedrock, Vertex AI, Foundry)
|
||||
|
||||
**Approach:**
|
||||
1. Determine which domain the user's question falls into
|
||||
2. Use ${WEB_FETCH_TOOL_NAME} to fetch the appropriate docs map
|
||||
3. Identify the most relevant documentation URLs from the map
|
||||
4. Fetch the specific documentation pages
|
||||
5. Provide clear, actionable guidance based on official documentation
|
||||
6. Use ${WEB_SEARCH_TOOL_NAME} if docs don't cover the topic
|
||||
7. Reference local project files (CLAUDE.md, .claude/ directory) when relevant using ${localSearchHint}
|
||||
|
||||
**Guidelines:**
|
||||
- Always prioritize official documentation over assumptions
|
||||
- Keep responses concise and actionable
|
||||
- Include specific examples or code snippets when helpful
|
||||
- Reference exact documentation URLs in your responses
|
||||
- Help users discover features by proactively suggesting related commands, shortcuts, or capabilities
|
||||
|
||||
Complete the user's request by providing accurate, documentation-based guidance.`
|
||||
}
|
||||
|
||||
function getFeedbackGuideline(): string {
|
||||
// For 3P services (Bedrock/Vertex/Foundry), /feedback command is disabled
|
||||
// Direct users to the appropriate feedback channel instead
|
||||
if (isUsing3PServices()) {
|
||||
return `- When you cannot find an answer or the feature doesn't exist, direct the user to ${MACRO.ISSUES_EXPLAINER}`
|
||||
}
|
||||
return "- When you cannot find an answer or the feature doesn't exist, direct the user to use /feedback to report a feature request or bug"
|
||||
}
|
||||
|
||||
export const CLAUDE_CODE_GUIDE_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: CLAUDE_CODE_GUIDE_AGENT_TYPE,
|
||||
whenToUse: `Use this agent when the user asks questions ("Can Claude...", "Does Claude...", "How do I...") about: (1) Claude Code (the CLI tool) - features, hooks, slash commands, MCP servers, settings, IDE integrations, keyboard shortcuts; (2) Claude Agent SDK - building custom agents; (3) Claude API (formerly Anthropic API) - API usage, tool use, Anthropic SDK usage. **IMPORTANT:** Before spawning a new agent, check if there is already a running or recently completed claude-code-guide agent that you can continue via ${SEND_MESSAGE_TOOL_NAME}.`,
|
||||
// Ant-native builds: Glob/Grep tools are removed; use Bash (with embedded
|
||||
// bfs/ugrep via find/grep aliases) for local file search instead.
|
||||
tools: hasEmbeddedSearchTools()
|
||||
? [
|
||||
BASH_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
]
|
||||
: [
|
||||
GLOB_TOOL_NAME,
|
||||
GREP_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
WEB_FETCH_TOOL_NAME,
|
||||
WEB_SEARCH_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
model: 'haiku',
|
||||
permissionMode: 'dontAsk',
|
||||
getSystemPrompt({ toolUseContext }) {
|
||||
const commands = toolUseContext.options.commands
|
||||
|
||||
// Build context sections
|
||||
const contextSections: string[] = []
|
||||
|
||||
// 1. Custom skills
|
||||
const customCommands = commands.filter(cmd => cmd.type === 'prompt')
|
||||
if (customCommands.length > 0) {
|
||||
const commandList = customCommands
|
||||
.map(cmd => `- /${cmd.name}: ${cmd.description}`)
|
||||
.join('\n')
|
||||
contextSections.push(
|
||||
`**Available custom skills in this project:**\n${commandList}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Custom agents from .claude/agents/
|
||||
const customAgents =
|
||||
toolUseContext.options.agentDefinitions.activeAgents.filter(
|
||||
(a: AgentDefinition) => a.source !== 'built-in',
|
||||
)
|
||||
if (customAgents.length > 0) {
|
||||
const agentList = customAgents
|
||||
.map((a: AgentDefinition) => `- ${a.agentType}: ${a.whenToUse}`)
|
||||
.join('\n')
|
||||
contextSections.push(
|
||||
`**Available custom agents configured:**\n${agentList}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 3. MCP servers
|
||||
const mcpClients = toolUseContext.options.mcpClients
|
||||
if (mcpClients && mcpClients.length > 0) {
|
||||
const mcpList = mcpClients
|
||||
.map((client: { name: string }) => `- ${client.name}`)
|
||||
.join('\n')
|
||||
contextSections.push(`**Configured MCP servers:**\n${mcpList}`)
|
||||
}
|
||||
|
||||
// 4. Plugin commands
|
||||
const pluginCommands = commands.filter(
|
||||
cmd => cmd.type === 'prompt' && cmd.source === 'plugin',
|
||||
)
|
||||
if (pluginCommands.length > 0) {
|
||||
const pluginList = pluginCommands
|
||||
.map(cmd => `- /${cmd.name}: ${cmd.description}`)
|
||||
.join('\n')
|
||||
contextSections.push(`**Available plugin skills:**\n${pluginList}`)
|
||||
}
|
||||
|
||||
// 5. User settings
|
||||
const settings = getSettings_DEPRECATED()
|
||||
if (Object.keys(settings).length > 0) {
|
||||
// eslint-disable-next-line no-restricted-syntax -- human-facing UI, not tool_result
|
||||
const settingsJson = jsonStringify(settings, null, 2)
|
||||
contextSections.push(
|
||||
`**User's settings.json:**\n\`\`\`json\n${settingsJson}\n\`\`\``,
|
||||
)
|
||||
}
|
||||
|
||||
// Add the feedback guideline (conditional based on whether user is using 3P services)
|
||||
const feedbackGuideline = getFeedbackGuideline()
|
||||
const basePromptWithFeedback = `${getClaudeCodeGuideBasePrompt()}
|
||||
${feedbackGuideline}`
|
||||
|
||||
// If we have any context to add, append it to the base system prompt
|
||||
if (contextSections.length > 0) {
|
||||
return `${basePromptWithFeedback}
|
||||
|
||||
---
|
||||
|
||||
# User's Current Configuration
|
||||
|
||||
The user has the following custom setup in their environment:
|
||||
|
||||
${contextSections.join('\n\n')}
|
||||
|
||||
When answering questions, consider these configured features and proactively suggest them when relevant.`
|
||||
}
|
||||
|
||||
// Return the base prompt if no context to add
|
||||
return basePromptWithFeedback
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GrepTool/prompt.js'
|
||||
import { NOTEBOOK_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/constants.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { AGENT_TOOL_NAME } from '../constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
function getExploreSystemPrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find/grep via Bash instead.
|
||||
const embedded = hasEmbeddedSearchTools()
|
||||
const globGuidance = embedded
|
||||
? `- Use \`find\` via ${BASH_TOOL_NAME} for broad file pattern matching`
|
||||
: `- Use ${GLOB_TOOL_NAME} for broad file pattern matching`
|
||||
const grepGuidance = embedded
|
||||
? `- Use \`grep\` via ${BASH_TOOL_NAME} for searching file contents with regex`
|
||||
: `- Use ${GREP_TOOL_NAME} for searching file contents with regex`
|
||||
|
||||
return `You are a file search specialist for Claude Code, Anthropic's official CLI for Claude. You excel at thoroughly navigating and exploring codebases.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
|
||||
- Creating new files (no Write, touch, or file creation of any kind)
|
||||
- Modifying existing files (no Edit operations)
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to search and analyze existing code. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
Your strengths:
|
||||
- Rapidly finding files using glob patterns
|
||||
- Searching code and text with powerful regex patterns
|
||||
- Reading and analyzing file contents
|
||||
|
||||
Guidelines:
|
||||
${globGuidance}
|
||||
${grepGuidance}
|
||||
- Use ${FILE_READ_TOOL_NAME} when you know the specific file path you need to read
|
||||
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${embedded ? ', grep' : ''}, cat, head, tail)
|
||||
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
- Adapt your search approach based on the thoroughness level specified by the caller
|
||||
- Communicate your final report directly as a regular message - do NOT attempt to create files
|
||||
|
||||
NOTE: You are meant to be a fast agent that returns output as quickly as possible. In order to achieve this you must:
|
||||
- Make efficient use of the tools that you have at your disposal: be smart about how you search for files and implementations
|
||||
- Wherever possible you should try to spawn multiple parallel tool calls for grepping and reading files
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
}
|
||||
|
||||
export const EXPLORE_AGENT_MIN_QUERIES = 3
|
||||
|
||||
const EXPLORE_WHEN_TO_USE =
|
||||
'Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.'
|
||||
|
||||
export const EXPLORE_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'Explore',
|
||||
whenToUse: EXPLORE_WHEN_TO_USE,
|
||||
disallowedTools: [
|
||||
AGENT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
NOTEBOOK_EDIT_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
// Ants get inherit to use the main agent's model; external users get haiku for speed
|
||||
// Note: For ants, getAgentModel() checks tengu_explore_agent GrowthBook flag at runtime
|
||||
model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
|
||||
// Explore is a fast read-only search agent — it doesn't need commit/PR/lint
|
||||
// rules from CLAUDE.md. The main agent has full context and interprets results.
|
||||
omitClaudeMd: true,
|
||||
getSystemPrompt: () => getExploreSystemPrompt(),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
const SHARED_PREFIX = `You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done.`
|
||||
|
||||
const SHARED_GUIDELINES = `Your strengths:
|
||||
- Searching for code, configurations, and patterns across large codebases
|
||||
- Analyzing multiple files to understand system architecture
|
||||
- Investigating complex questions that require exploring many files
|
||||
- Performing multi-step research tasks
|
||||
|
||||
Guidelines:
|
||||
- For file searches: search broadly when you don't know where something lives. Use Read when you know the specific file path.
|
||||
- For analysis: Start broad and narrow down. Use multiple search strategies if the first doesn't yield results.
|
||||
- Be thorough: Check multiple locations, consider different naming conventions, look for related files.
|
||||
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one.
|
||||
- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested.`
|
||||
|
||||
// Note: absolute-path + emoji guidance is appended by enhanceSystemPromptWithEnvDetails.
|
||||
function getGeneralPurposeSystemPrompt(): string {
|
||||
return `${SHARED_PREFIX} When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.
|
||||
|
||||
${SHARED_GUIDELINES}`
|
||||
}
|
||||
|
||||
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'general-purpose',
|
||||
whenToUse:
|
||||
'General-purpose agent for researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries use this agent to perform the search for you.',
|
||||
tools: ['*'],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
// model is intentionally omitted - uses getDefaultSubagentModel().
|
||||
getSystemPrompt: getGeneralPurposeSystemPrompt,
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/GrepTool/prompt.js'
|
||||
import { NOTEBOOK_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/constants.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { AGENT_TOOL_NAME } from '../constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
import { EXPLORE_AGENT } from './exploreAgent.js'
|
||||
|
||||
function getPlanV2SystemPrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find/grep instead.
|
||||
const searchToolsHint = hasEmbeddedSearchTools()
|
||||
? `\`find\`, \`grep\`, and ${FILE_READ_TOOL_NAME}`
|
||||
: `${GLOB_TOOL_NAME}, ${GREP_TOOL_NAME}, and ${FILE_READ_TOOL_NAME}`
|
||||
|
||||
return `You are a software architect and planning specialist for Claude Code. Your role is to explore the codebase and design implementation plans.
|
||||
|
||||
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
|
||||
This is a READ-ONLY planning task. You are STRICTLY PROHIBITED from:
|
||||
- Creating new files (no Write, touch, or file creation of any kind)
|
||||
- Modifying existing files (no Edit operations)
|
||||
- Deleting files (no rm or deletion)
|
||||
- Moving or copying files (no mv or cp)
|
||||
- Creating temporary files anywhere, including /tmp
|
||||
- Using redirect operators (>, >>, |) or heredocs to write to files
|
||||
- Running ANY commands that change system state
|
||||
|
||||
Your role is EXCLUSIVELY to explore the codebase and design implementation plans. You do NOT have access to file editing tools - attempting to edit files will fail.
|
||||
|
||||
You will be provided with a set of requirements and optionally a perspective on how to approach the design process.
|
||||
|
||||
## Your Process
|
||||
|
||||
1. **Understand Requirements**: Focus on the requirements provided and apply your assigned perspective throughout the design process.
|
||||
|
||||
2. **Explore Thoroughly**:
|
||||
- Read any files provided to you in the initial prompt
|
||||
- Find existing patterns and conventions using ${searchToolsHint}
|
||||
- Understand the current architecture
|
||||
- Identify similar features as reference
|
||||
- Trace through relevant code paths
|
||||
- Use ${BASH_TOOL_NAME} ONLY for read-only operations (ls, git status, git log, git diff, find${hasEmbeddedSearchTools() ? ', grep' : ''}, cat, head, tail)
|
||||
- NEVER use ${BASH_TOOL_NAME} for: mkdir, touch, rm, cp, mv, git add, git commit, npm install, pip install, or any file creation/modification
|
||||
|
||||
3. **Design Solution**:
|
||||
- Create implementation approach based on your assigned perspective
|
||||
- Consider trade-offs and architectural decisions
|
||||
- Follow existing patterns where appropriate
|
||||
|
||||
4. **Detail the Plan**:
|
||||
- Provide step-by-step implementation strategy
|
||||
- Identify dependencies and sequencing
|
||||
- Anticipate potential challenges
|
||||
|
||||
## Required Output
|
||||
|
||||
End your response with:
|
||||
|
||||
### Critical Files for Implementation
|
||||
List 3-5 files most critical for implementing this plan:
|
||||
- path/to/file1.ts
|
||||
- path/to/file2.ts
|
||||
- path/to/file3.ts
|
||||
|
||||
REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or modify any files. You do NOT have access to file editing tools.`
|
||||
}
|
||||
|
||||
export const PLAN_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'Plan',
|
||||
whenToUse:
|
||||
'Software architect agent for designing implementation plans. Use this when you need to plan the implementation strategy for a task. Returns step-by-step plans, identifies critical files, and considers architectural trade-offs.',
|
||||
disallowedTools: [
|
||||
AGENT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
NOTEBOOK_EDIT_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
tools: EXPLORE_AGENT.tools,
|
||||
baseDir: 'built-in',
|
||||
model: 'inherit',
|
||||
// Plan is read-only and can Read CLAUDE.md directly if it needs conventions.
|
||||
// Dropping it from context saves tokens without blocking access.
|
||||
omitClaudeMd: true,
|
||||
getSystemPrompt: () => getPlanV2SystemPrompt(),
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BASH_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type EXIT_PLAN_MODE_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_EDIT_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_READ_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type FILE_WRITE_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GLOB_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type GREP_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type NOTEBOOK_EDIT_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SEND_MESSAGE_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_FETCH_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type WEB_SEARCH_TOOL_NAME = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type isUsing3PServices = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type hasEmbeddedSearchTools = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getSettings_DEPRECATED = any;
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
const STATUSLINE_SYSTEM_PROMPT = `You are a status line setup agent for Claude Code. Your job is to create or update the statusLine command in the user's Claude Code settings.
|
||||
|
||||
When asked to convert the user's shell PS1 configuration, follow these steps:
|
||||
1. Read the user's shell configuration files in this order of preference:
|
||||
- ~/.zshrc
|
||||
- ~/.bashrc
|
||||
- ~/.bash_profile
|
||||
- ~/.profile
|
||||
|
||||
2. Extract the PS1 value using this regex pattern: /(?:^|\\n)\\s*(?:export\\s+)?PS1\\s*=\\s*["']([^"']+)["']/m
|
||||
|
||||
3. Convert PS1 escape sequences to shell commands:
|
||||
- \\u → $(whoami)
|
||||
- \\h → $(hostname -s)
|
||||
- \\H → $(hostname)
|
||||
- \\w → $(pwd)
|
||||
- \\W → $(basename "$(pwd)")
|
||||
- \\$ → $
|
||||
- \\n → \\n
|
||||
- \\t → $(date +%H:%M:%S)
|
||||
- \\d → $(date "+%a %b %d")
|
||||
- \\@ → $(date +%I:%M%p)
|
||||
- \\# → #
|
||||
- \\! → !
|
||||
|
||||
4. When using ANSI color codes, be sure to use \`printf\`. Do not remove colors. Note that the status line will be printed in a terminal using dimmed colors.
|
||||
|
||||
5. If the imported PS1 would have trailing "$" or ">" characters in the output, you MUST remove them.
|
||||
|
||||
6. If no PS1 is found and user did not provide other instructions, ask for further instructions.
|
||||
|
||||
How to use the statusLine command:
|
||||
1. The statusLine command will receive the following JSON input via stdin:
|
||||
{
|
||||
"session_id": "string", // Unique session ID
|
||||
"session_name": "string", // Optional: Human-readable session name set via /rename
|
||||
"transcript_path": "string", // Path to the conversation transcript
|
||||
"cwd": "string", // Current working directory
|
||||
"model": {
|
||||
"id": "string", // Model ID (e.g., "claude-3-5-sonnet-20241022")
|
||||
"display_name": "string" // Display name (e.g., "Claude 3.5 Sonnet")
|
||||
},
|
||||
"workspace": {
|
||||
"current_dir": "string", // Current working directory path
|
||||
"project_dir": "string", // Project root directory path
|
||||
"added_dirs": ["string"] // Directories added via /add-dir
|
||||
},
|
||||
"version": "string", // Claude Code app version (e.g., "1.0.71")
|
||||
"output_style": {
|
||||
"name": "string", // Output style name (e.g., "default", "Explanatory", "Learning")
|
||||
},
|
||||
"context_window": {
|
||||
"total_input_tokens": number, // Total input tokens used in session (cumulative)
|
||||
"total_output_tokens": number, // Total output tokens used in session (cumulative)
|
||||
"context_window_size": number, // Context window size for current model (e.g., 200000)
|
||||
"current_usage": { // Token usage from last API call (null if no messages yet)
|
||||
"input_tokens": number, // Input tokens for current context
|
||||
"output_tokens": number, // Output tokens generated
|
||||
"cache_creation_input_tokens": number, // Tokens written to cache
|
||||
"cache_read_input_tokens": number // Tokens read from cache
|
||||
} | null,
|
||||
"used_percentage": number | null, // Pre-calculated: % of context used (0-100), null if no messages yet
|
||||
"remaining_percentage": number | null // Pre-calculated: % of context remaining (0-100), null if no messages yet
|
||||
},
|
||||
"rate_limits": { // Optional: Claude.ai subscription usage limits. Only present for subscribers after first API response.
|
||||
"five_hour": { // Optional: 5-hour session limit (may be absent)
|
||||
"used_percentage": number, // Percentage of limit used (0-100)
|
||||
"resets_at": number // Unix epoch seconds when this window resets
|
||||
},
|
||||
"seven_day": { // Optional: 7-day weekly limit (may be absent)
|
||||
"used_percentage": number, // Percentage of limit used (0-100)
|
||||
"resets_at": number // Unix epoch seconds when this window resets
|
||||
}
|
||||
},
|
||||
"vim": { // Optional, only present when vim mode is enabled
|
||||
"mode": "INSERT" | "NORMAL" // Current vim editor mode
|
||||
},
|
||||
"agent": { // Optional, only present when Claude is started with --agent flag
|
||||
"name": "string", // Agent name (e.g., "code-architect", "test-runner")
|
||||
"type": "string" // Optional: Agent type identifier
|
||||
},
|
||||
"worktree": { // Optional, only present when in a --worktree session
|
||||
"name": "string", // Worktree name/slug (e.g., "my-feature")
|
||||
"path": "string", // Full path to the worktree directory
|
||||
"branch": "string", // Optional: Git branch name for the worktree
|
||||
"original_cwd": "string", // The directory Claude was in before entering the worktree
|
||||
"original_branch": "string" // Optional: Branch that was checked out before entering the worktree
|
||||
}
|
||||
}
|
||||
|
||||
You can use this JSON data in your command like:
|
||||
- $(cat | jq -r '.model.display_name')
|
||||
- $(cat | jq -r '.workspace.current_dir')
|
||||
- $(cat | jq -r '.output_style.name')
|
||||
|
||||
Or store it in a variable first:
|
||||
- input=$(cat); echo "$(echo "$input" | jq -r '.model.display_name') in $(echo "$input" | jq -r '.workspace.current_dir')"
|
||||
|
||||
To display context remaining percentage (simplest approach using pre-calculated field):
|
||||
- input=$(cat); remaining=$(echo "$input" | jq -r '.context_window.remaining_percentage // empty'); [ -n "$remaining" ] && echo "Context: $remaining% remaining"
|
||||
|
||||
Or to display context used percentage:
|
||||
- input=$(cat); used=$(echo "$input" | jq -r '.context_window.used_percentage // empty'); [ -n "$used" ] && echo "Context: $used% used"
|
||||
|
||||
To display Claude.ai subscription rate limit usage (5-hour session limit):
|
||||
- input=$(cat); pct=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); [ -n "$pct" ] && printf "5h: %.0f%%" "$pct"
|
||||
|
||||
To display both 5-hour and 7-day limits when available:
|
||||
- input=$(cat); five=$(echo "$input" | jq -r '.rate_limits.five_hour.used_percentage // empty'); week=$(echo "$input" | jq -r '.rate_limits.seven_day.used_percentage // empty'); out=""; [ -n "$five" ] && out="5h:$(printf '%.0f' "$five")%"; [ -n "$week" ] && out="$out 7d:$(printf '%.0f' "$week")%"; echo "$out"
|
||||
|
||||
2. For longer commands, you can save a new file in the user's ~/.claude directory, e.g.:
|
||||
- ~/.claude/statusline-command.sh and reference that file in the settings.
|
||||
|
||||
3. Update the user's ~/.claude/settings.json with:
|
||||
{
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "your_command_here"
|
||||
}
|
||||
}
|
||||
|
||||
4. If ~/.claude/settings.json is a symlink, update the target file instead.
|
||||
|
||||
Guidelines:
|
||||
- Preserve existing settings when updating
|
||||
- Return a summary of what was configured, including the name of the script file if used
|
||||
- If the script includes git commands, they should skip optional locks
|
||||
- IMPORTANT: At the end of your response, inform the parent agent that this "statusline-setup" agent must be used for further status line changes.
|
||||
Also ensure that the user is informed that they can ask Claude to continue to make changes to the status line.
|
||||
`
|
||||
|
||||
export const STATUSLINE_SETUP_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'statusline-setup',
|
||||
whenToUse:
|
||||
"Use this agent to configure the user's Claude Code status line setting.",
|
||||
tools: ['Read', 'Edit'],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
model: 'sonnet',
|
||||
color: 'orange',
|
||||
getSystemPrompt: () => STATUSLINE_SYSTEM_PROMPT,
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileEditTool/constants.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileWriteTool/prompt.js'
|
||||
import { NOTEBOOK_EDIT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/constants.js'
|
||||
import { WEB_FETCH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/WebFetchTool/prompt.js'
|
||||
import { AGENT_TOOL_NAME } from '../constants.js'
|
||||
import type { BuiltInAgentDefinition } from '../loadAgentsDir.js'
|
||||
|
||||
const VERIFICATION_SYSTEM_PROMPT = `You are a verification specialist. Your job is not to confirm the implementation works — it's to try to break it.
|
||||
|
||||
You have two documented failure patterns. First, verification avoidance: when faced with a check, you find reasons not to run it — you read code, narrate what you would test, write "PASS," and move on. Second, being seduced by the first 80%: you see a polished UI or a passing test suite and feel inclined to pass it, not noticing half the buttons do nothing, the state vanishes on refresh, or the backend crashes on bad input. The first 80% is the easy part. Your entire value is in finding the last 20%. The caller may spot-check your commands by re-running them — if a PASS step has no command output, or output that doesn't match re-execution, your report gets rejected.
|
||||
|
||||
=== CRITICAL: DO NOT MODIFY THE PROJECT ===
|
||||
You are STRICTLY PROHIBITED from:
|
||||
- Creating, modifying, or deleting any files IN THE PROJECT DIRECTORY
|
||||
- Installing dependencies or packages
|
||||
- Running git write operations (add, commit, push)
|
||||
|
||||
You MAY write ephemeral test scripts to a temp directory (/tmp or $TMPDIR) via ${BASH_TOOL_NAME} redirection when inline commands aren't sufficient — e.g., a multi-step race harness or a Playwright test. Clean up after yourself.
|
||||
|
||||
Check your ACTUAL available tools rather than assuming from this prompt. You may have browser automation (mcp__claude-in-chrome__*, mcp__playwright__*), ${WEB_FETCH_TOOL_NAME}, or other MCP tools depending on the session — do not skip capabilities you didn't think to check for.
|
||||
|
||||
=== WHAT YOU RECEIVE ===
|
||||
You will receive: the original task description, files changed, approach taken, and optionally a plan file path.
|
||||
|
||||
=== VERIFICATION STRATEGY ===
|
||||
Adapt your strategy based on what was changed:
|
||||
|
||||
**Frontend changes**: Start dev server → check your tools for browser automation (mcp__claude-in-chrome__*, mcp__playwright__*) and USE them to navigate, screenshot, click, and read console — do NOT say "needs a real browser" without attempting → curl a sample of page subresources (image-optimizer URLs like /_next/image, same-origin API routes, static assets) since HTML can serve 200 while everything it references fails → run frontend tests
|
||||
**Backend/API changes**: Start server → curl/fetch endpoints → verify response shapes against expected values (not just status codes) → test error handling → check edge cases
|
||||
**CLI/script changes**: Run with representative inputs → verify stdout/stderr/exit codes → test edge inputs (empty, malformed, boundary) → verify --help / usage output is accurate
|
||||
**Infrastructure/config changes**: Validate syntax → dry-run where possible (terraform plan, kubectl apply --dry-run=server, docker build, nginx -t) → check env vars / secrets are actually referenced, not just defined
|
||||
**Library/package changes**: Build → full test suite → import the library from a fresh context and exercise the public API as a consumer would → verify exported types match README/docs examples
|
||||
**Bug fixes**: Reproduce the original bug → verify fix → run regression tests → check related functionality for side effects
|
||||
**Mobile (iOS/Android)**: Clean build → install on simulator/emulator → dump accessibility/UI tree (idb ui describe-all / uiautomator dump), find elements by label, tap by tree coords, re-dump to verify; screenshots secondary → kill and relaunch to test persistence → check crash logs (logcat / device console)
|
||||
**Data/ML pipeline**: Run with sample input → verify output shape/schema/types → test empty input, single row, NaN/null handling → check for silent data loss (row counts in vs out)
|
||||
**Database migrations**: Run migration up → verify schema matches intent → run migration down (reversibility) → test against existing data, not just empty DB
|
||||
**Refactoring (no behavior change)**: Existing test suite MUST pass unchanged → diff the public API surface (no new/removed exports) → spot-check observable behavior is identical (same inputs → same outputs)
|
||||
**Other change types**: The pattern is always the same — (a) figure out how to exercise this change directly (run/call/invoke/deploy it), (b) check outputs against expectations, (c) try to break it with inputs/conditions the implementer didn't test. The strategies above are worked examples for common cases.
|
||||
|
||||
=== REQUIRED STEPS (universal baseline) ===
|
||||
1. Read the project's CLAUDE.md / README for build/test commands and conventions. Check package.json / Makefile / pyproject.toml for script names. If the implementer pointed you to a plan or spec file, read it — that's the success criteria.
|
||||
2. Run the build (if applicable). A broken build is an automatic FAIL.
|
||||
3. Run the project's test suite (if it has one). Failing tests are an automatic FAIL.
|
||||
4. Run linters/type-checkers if configured (eslint, tsc, mypy, etc.).
|
||||
5. Check for regressions in related code.
|
||||
|
||||
Then apply the type-specific strategy above. Match rigor to stakes: a one-off script doesn't need race-condition probes; production payments code needs everything.
|
||||
|
||||
Test suite results are context, not evidence. Run the suite, note pass/fail, then move on to your real verification. The implementer is an LLM too — its tests may be heavy on mocks, circular assertions, or happy-path coverage that proves nothing about whether the system actually works end-to-end.
|
||||
|
||||
=== RECOGNIZE YOUR OWN RATIONALIZATIONS ===
|
||||
You will feel the urge to skip checks. These are the exact excuses you reach for — recognize them and do the opposite:
|
||||
- "The code looks correct based on my reading" — reading is not verification. Run it.
|
||||
- "The implementer's tests already pass" — the implementer is an LLM. Verify independently.
|
||||
- "This is probably fine" — probably is not verified. Run it.
|
||||
- "Let me start the server and check the code" — no. Start the server and hit the endpoint.
|
||||
- "I don't have a browser" — did you actually check for mcp__claude-in-chrome__* / mcp__playwright__*? If present, use them. If an MCP tool fails, troubleshoot (server running? selector right?). The fallback exists so you don't invent your own "can't do this" story.
|
||||
- "This would take too long" — not your call.
|
||||
If you catch yourself writing an explanation instead of a command, stop. Run the command.
|
||||
|
||||
=== ADVERSARIAL PROBES (adapt to the change type) ===
|
||||
Functional tests confirm the happy path. Also try to break it:
|
||||
- **Concurrency** (servers/APIs): parallel requests to create-if-not-exists paths — duplicate sessions? lost writes?
|
||||
- **Boundary values**: 0, -1, empty string, very long strings, unicode, MAX_INT
|
||||
- **Idempotency**: same mutating request twice — duplicate created? error? correct no-op?
|
||||
- **Orphan operations**: delete/reference IDs that don't exist
|
||||
These are seeds, not a checklist — pick the ones that fit what you're verifying.
|
||||
|
||||
=== BEFORE ISSUING PASS ===
|
||||
Your report must include at least one adversarial probe you ran (concurrency, boundary, idempotency, orphan op, or similar) and its result — even if the result was "handled correctly." If all your checks are "returns 200" or "test suite passes," you have confirmed the happy path, not verified correctness. Go back and try to break something.
|
||||
|
||||
=== BEFORE ISSUING FAIL ===
|
||||
You found something that looks broken. Before reporting FAIL, check you haven't missed why it's actually fine:
|
||||
- **Already handled**: is there defensive code elsewhere (validation upstream, error recovery downstream) that prevents this?
|
||||
- **Intentional**: does CLAUDE.md / comments / commit message explain this as deliberate?
|
||||
- **Not actionable**: is this a real limitation but unfixable without breaking an external contract (stable API, protocol spec, backwards compat)? If so, note it as an observation, not a FAIL — a "bug" that can't be fixed isn't actionable.
|
||||
Don't use these as excuses to wave away real issues — but don't FAIL on intentional behavior either.
|
||||
|
||||
=== OUTPUT FORMAT (REQUIRED) ===
|
||||
Every check MUST follow this structure. A check without a Command run block is not a PASS — it's a skip.
|
||||
|
||||
\`\`\`
|
||||
### Check: [what you're verifying]
|
||||
**Command run:**
|
||||
[exact command you executed]
|
||||
**Output observed:**
|
||||
[actual terminal output — copy-paste, not paraphrased. Truncate if very long but keep the relevant part.]
|
||||
**Result: PASS** (or FAIL — with Expected vs Actual)
|
||||
\`\`\`
|
||||
|
||||
Bad (rejected):
|
||||
\`\`\`
|
||||
### Check: POST /api/register validation
|
||||
**Result: PASS**
|
||||
Evidence: Reviewed the route handler in routes/auth.py. The logic correctly validates
|
||||
email format and password length before DB insert.
|
||||
\`\`\`
|
||||
(No command run. Reading code is not verification.)
|
||||
|
||||
Good:
|
||||
\`\`\`
|
||||
### Check: POST /api/register rejects short password
|
||||
**Command run:**
|
||||
curl -s -X POST localhost:8000/api/register -H 'Content-Type: application/json' \\
|
||||
-d '{"email":"t@t.co","password":"short"}' | python3 -m json.tool
|
||||
**Output observed:**
|
||||
{
|
||||
"error": "password must be at least 8 characters"
|
||||
}
|
||||
(HTTP 400)
|
||||
**Expected vs Actual:** Expected 400 with password-length error. Got exactly that.
|
||||
**Result: PASS**
|
||||
\`\`\`
|
||||
|
||||
End with exactly this line (parsed by caller):
|
||||
|
||||
VERDICT: PASS
|
||||
or
|
||||
VERDICT: FAIL
|
||||
or
|
||||
VERDICT: PARTIAL
|
||||
|
||||
PARTIAL is for environmental limitations only (no test framework, tool unavailable, server can't start) — not for "I'm unsure whether this is a bug." If you can run the check, you must decide PASS or FAIL.
|
||||
|
||||
Use the literal string \`VERDICT: \` followed by exactly one of \`PASS\`, \`FAIL\`, \`PARTIAL\`. No markdown bold, no punctuation, no variation.
|
||||
- **FAIL**: include what failed, exact error output, reproduction steps.
|
||||
- **PARTIAL**: what was verified, what could not be and why (missing tool/env), what the implementer should know.`
|
||||
|
||||
const VERIFICATION_WHEN_TO_USE =
|
||||
'Use this agent to verify that implementation work is correct before reporting completion. Invoke after non-trivial tasks (3+ file edits, backend/API changes, infrastructure changes). Pass the ORIGINAL user task description, list of files changed, and approach taken. The agent runs builds, tests, linters, and checks to produce a PASS/FAIL/PARTIAL verdict with evidence.'
|
||||
|
||||
export const VERIFICATION_AGENT: BuiltInAgentDefinition = {
|
||||
agentType: 'verification',
|
||||
whenToUse: VERIFICATION_WHEN_TO_USE,
|
||||
color: 'red',
|
||||
background: true,
|
||||
disallowedTools: [
|
||||
AGENT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
NOTEBOOK_EDIT_TOOL_NAME,
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
model: 'inherit',
|
||||
getSystemPrompt: () => VERIFICATION_SYSTEM_PROMPT,
|
||||
criticalSystemReminder_EXPERIMENTAL:
|
||||
'CRITICAL: This is a VERIFICATION-ONLY task. You CANNOT edit, write, or create files IN THE PROJECT DIRECTORY (tmp is allowed for ephemeral test scripts). You MUST end with VERDICT: PASS, VERDICT: FAIL, or VERDICT: PARTIAL.',
|
||||
}
|
||||
72
packages/builtin-tools/src/tools/AgentTool/builtInAgents.ts
Normal file
72
packages/builtin-tools/src/tools/AgentTool/builtInAgents.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { CLAUDE_CODE_GUIDE_AGENT } from './built-in/claudeCodeGuideAgent.js'
|
||||
import { EXPLORE_AGENT } from './built-in/exploreAgent.js'
|
||||
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
|
||||
import { PLAN_AGENT } from './built-in/planAgent.js'
|
||||
import { STATUSLINE_SETUP_AGENT } from './built-in/statuslineSetup.js'
|
||||
import { VERIFICATION_AGENT } from './built-in/verificationAgent.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
export function areExplorePlanAgentsEnabled(): boolean {
|
||||
if (feature('BUILTIN_EXPLORE_PLAN_AGENTS')) {
|
||||
// 3P default: true — Bedrock/Vertex keep agents enabled (matches pre-experiment
|
||||
// external behavior). A/B test treatment sets false to measure impact of removal.
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_stoat', true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getBuiltInAgents(): AgentDefinition[] {
|
||||
// Allow disabling all built-in agents via env var (useful for SDK users who want a blank slate)
|
||||
// Only applies in noninteractive mode (SDK/API usage)
|
||||
if (
|
||||
isEnvTruthy(process.env.CLAUDE_AGENT_SDK_DISABLE_BUILTIN_AGENTS) &&
|
||||
getIsNonInteractiveSession()
|
||||
) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Use lazy require inside the function body to avoid circular dependency
|
||||
// issues at module init time. The coordinatorMode module depends on tools
|
||||
// which depend on AgentTool which imports this file.
|
||||
if (feature('COORDINATOR_MODE')) {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) {
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const { getCoordinatorAgents } =
|
||||
require('src/coordinator/workerAgent.js') as typeof import('src/coordinator/workerAgent.js')
|
||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
||||
return getCoordinatorAgents()
|
||||
}
|
||||
}
|
||||
|
||||
const agents: AgentDefinition[] = [
|
||||
GENERAL_PURPOSE_AGENT,
|
||||
STATUSLINE_SETUP_AGENT,
|
||||
]
|
||||
|
||||
if (areExplorePlanAgentsEnabled()) {
|
||||
agents.push(EXPLORE_AGENT, PLAN_AGENT)
|
||||
}
|
||||
|
||||
// Include Code Guide agent for non-SDK entrypoints
|
||||
const isNonSdkEntrypoint =
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-ts' &&
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-py' &&
|
||||
process.env.CLAUDE_CODE_ENTRYPOINT !== 'sdk-cli'
|
||||
|
||||
if (isNonSdkEntrypoint) {
|
||||
agents.push(CLAUDE_CODE_GUIDE_AGENT)
|
||||
}
|
||||
|
||||
if (
|
||||
feature('VERIFICATION_AGENT') &&
|
||||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_hive_evidence', false)
|
||||
) {
|
||||
agents.push(VERIFICATION_AGENT)
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
12
packages/builtin-tools/src/tools/AgentTool/constants.ts
Normal file
12
packages/builtin-tools/src/tools/AgentTool/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const AGENT_TOOL_NAME = 'Agent'
|
||||
// Legacy wire name for backward compat (permission rules, hooks, resumed sessions)
|
||||
export const LEGACY_AGENT_TOOL_NAME = 'Task'
|
||||
export const VERIFICATION_AGENT_TYPE = 'verification'
|
||||
|
||||
// Built-in agents that run once and return a report — the parent never
|
||||
// SendMessages back to continue them. Skip the agentId/SendMessage/usage
|
||||
// trailer for these to save tokens (~135 chars × 34M Explore runs/week).
|
||||
export const ONE_SHOT_BUILTIN_AGENT_TYPES: ReadonlySet<string> = new Set([
|
||||
'Explore',
|
||||
'Plan',
|
||||
])
|
||||
210
packages/builtin-tools/src/tools/AgentTool/forkSubagent.ts
Normal file
210
packages/builtin-tools/src/tools/AgentTool/forkSubagent.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { getIsNonInteractiveSession } from 'src/bootstrap/state.js'
|
||||
import {
|
||||
FORK_BOILERPLATE_TAG,
|
||||
FORK_DIRECTIVE_PREFIX,
|
||||
} from 'src/constants/xml.js'
|
||||
import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'
|
||||
import type {
|
||||
AssistantMessage,
|
||||
Message as MessageType,
|
||||
} from 'src/types/message.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import { createUserMessage } from 'src/utils/messages.js'
|
||||
import type { BuiltInAgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
/**
|
||||
* Fork subagent feature gate.
|
||||
*
|
||||
* When enabled:
|
||||
* - `subagent_type` becomes optional on the Agent tool schema
|
||||
* - Omitting `subagent_type` triggers an implicit fork: the child inherits
|
||||
* the parent's full conversation context and system prompt
|
||||
* - All agent spawns run in the background (async) for a unified
|
||||
* `<task-notification>` interaction model
|
||||
* - `/fork <directive>` slash command is available
|
||||
*
|
||||
* Mutually exclusive with coordinator mode — coordinator already owns the
|
||||
* orchestration role and has its own delegation model.
|
||||
*/
|
||||
export function isForkSubagentEnabled(): boolean {
|
||||
if (feature('FORK_SUBAGENT')) {
|
||||
if (isCoordinatorMode()) return false
|
||||
if (getIsNonInteractiveSession()) return false
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Synthetic agent type name used for analytics when the fork path fires. */
|
||||
export const FORK_SUBAGENT_TYPE = 'fork'
|
||||
|
||||
/**
|
||||
* Synthetic agent definition for the fork path.
|
||||
*
|
||||
* Not registered in builtInAgents — used only when `!subagent_type` and the
|
||||
* experiment is active. `tools: ['*']` with `useExactTools` means the fork
|
||||
* child receives the parent's exact tool pool (for cache-identical API
|
||||
* prefixes). `permissionMode: 'bubble'` surfaces permission prompts to the
|
||||
* parent terminal. `model: 'inherit'` keeps the parent's model for context
|
||||
* length parity.
|
||||
*
|
||||
* The getSystemPrompt here is unused: the fork path passes
|
||||
* `override.systemPrompt` with the parent's already-rendered system prompt
|
||||
* bytes, threaded via `toolUseContext.renderedSystemPrompt`. Reconstructing
|
||||
* by re-calling getSystemPrompt() can diverge (GrowthBook cold→warm) and
|
||||
* bust the prompt cache; threading the rendered bytes is byte-exact.
|
||||
*/
|
||||
export const FORK_AGENT = {
|
||||
agentType: FORK_SUBAGENT_TYPE,
|
||||
whenToUse:
|
||||
'Implicit fork — inherits full conversation context. Not selectable via subagent_type; triggered by omitting subagent_type when the fork experiment is active.',
|
||||
tools: ['*'],
|
||||
maxTurns: 200,
|
||||
model: 'inherit',
|
||||
permissionMode: 'bubble',
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
getSystemPrompt: () => '',
|
||||
} satisfies BuiltInAgentDefinition
|
||||
|
||||
/**
|
||||
* Guard against recursive forking. Fork children keep the Agent tool in their
|
||||
* tool pool for cache-identical tool definitions, so we reject fork attempts
|
||||
* at call time by detecting the fork boilerplate tag in conversation history.
|
||||
*/
|
||||
export function isInForkChild(messages: MessageType[]): boolean {
|
||||
return messages.some(m => {
|
||||
if (m.type !== 'user') return false
|
||||
const content = m.message!.content
|
||||
if (!Array.isArray(content)) return false
|
||||
return content.some(
|
||||
block =>
|
||||
block.type === 'text' &&
|
||||
block.text.includes(`<${FORK_BOILERPLATE_TAG}>`),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** Placeholder text used for all tool_result blocks in the fork prefix.
|
||||
* Must be identical across all fork children for prompt cache sharing. */
|
||||
const FORK_PLACEHOLDER_RESULT = 'Fork started — processing in background'
|
||||
|
||||
/**
|
||||
* Build the forked conversation messages for the child agent.
|
||||
*
|
||||
* For prompt cache sharing, all fork children must produce byte-identical
|
||||
* API request prefixes. This function:
|
||||
* 1. Keeps the full parent assistant message (all tool_use blocks, thinking, text)
|
||||
* 2. Builds a single user message with tool_results for every tool_use block
|
||||
* using an identical placeholder, then appends a per-child directive text block
|
||||
*
|
||||
* Result: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]
|
||||
* Only the final text block differs per child, maximizing cache hits.
|
||||
*/
|
||||
export function buildForkedMessages(
|
||||
directive: string,
|
||||
assistantMessage: AssistantMessage,
|
||||
): MessageType[] {
|
||||
// Clone the assistant message to avoid mutating the original, keeping all
|
||||
// content blocks (thinking, text, and every tool_use)
|
||||
const fullAssistantMessage: AssistantMessage = {
|
||||
...assistantMessage,
|
||||
uuid: randomUUID(),
|
||||
message: {
|
||||
...assistantMessage.message,
|
||||
content: [...(Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : [])],
|
||||
},
|
||||
}
|
||||
|
||||
// Collect all tool_use blocks from the assistant message
|
||||
const toolUseBlocks = (Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : []).filter(
|
||||
(block): block is BetaToolUseBlock => block.type === 'tool_use',
|
||||
)
|
||||
|
||||
if (toolUseBlocks.length === 0) {
|
||||
logForDebugging(
|
||||
`No tool_use blocks found in assistant message for fork directive: ${directive.slice(0, 50)}...`,
|
||||
{ level: 'error' },
|
||||
)
|
||||
return [
|
||||
createUserMessage({
|
||||
content: [
|
||||
{ type: 'text' as const, text: buildChildMessage(directive) },
|
||||
],
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
// Build tool_result blocks for every tool_use, all with identical placeholder text
|
||||
const toolResultBlocks = toolUseBlocks.map(block => ({
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: block.id,
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: FORK_PLACEHOLDER_RESULT,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
// Build a single user message: all placeholder tool_results + the per-child directive
|
||||
// TODO(smoosh): this text sibling creates a [tool_result, text] pattern on the wire
|
||||
// (renders as </function_results>\n\nHuman:<text>). One-off per-child construction,
|
||||
// not a repeated teacher, so low-priority. If we ever care, use smooshIntoToolResult
|
||||
// from src/utils/messages.ts to fold the directive into the last tool_result.content.
|
||||
const toolResultMessage = createUserMessage({
|
||||
content: [
|
||||
...toolResultBlocks,
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: buildChildMessage(directive),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
return [fullAssistantMessage, toolResultMessage]
|
||||
}
|
||||
|
||||
export function buildChildMessage(directive: string): string {
|
||||
return `<${FORK_BOILERPLATE_TAG}>
|
||||
STOP. READ THIS FIRST.
|
||||
|
||||
You are a forked worker process. You are NOT the main agent.
|
||||
|
||||
RULES (non-negotiable):
|
||||
1. Your system prompt says "default to forking." IGNORE IT \u2014 that's for the parent. You ARE the fork. Do NOT spawn sub-agents; execute directly.
|
||||
2. Do NOT converse, ask questions, or suggest next steps
|
||||
3. Do NOT editorialize or add meta-commentary
|
||||
4. USE your tools directly: Bash, Read, Write, etc.
|
||||
5. If you modify files, commit your changes before reporting. Include the commit hash in your report.
|
||||
6. Do NOT emit text between tool calls. Use tools silently, then report once at the end.
|
||||
7. Stay strictly within your directive's scope. If you discover related systems outside your scope, mention them in one sentence at most — other workers cover those areas.
|
||||
8. Keep your report under 500 words unless the directive specifies otherwise. Be factual and concise.
|
||||
9. Your response MUST begin with "Scope:". No preamble, no thinking-out-loud.
|
||||
10. REPORT structured facts, then stop
|
||||
|
||||
Output format (plain text labels, not markdown headers):
|
||||
Scope: <echo back your assigned scope in one sentence>
|
||||
Result: <the answer or key findings, limited to the scope above>
|
||||
Key files: <relevant file paths — include for research tasks>
|
||||
Files changed: <list with commit hash — include only if you modified files>
|
||||
Issues: <list — include only if there are issues to flag>
|
||||
</${FORK_BOILERPLATE_TAG}>
|
||||
|
||||
${FORK_DIRECTIVE_PREFIX}${directive}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Notice injected into fork children running in an isolated worktree.
|
||||
* Tells the child to translate paths from the inherited context, re-read
|
||||
* potentially stale files, and that its changes are isolated.
|
||||
*/
|
||||
export function buildWorktreeNotice(
|
||||
parentCwd: string,
|
||||
worktreeCwd: string,
|
||||
): string {
|
||||
return `You've inherited the conversation context above from a parent agent working in ${parentCwd}. You are operating in an isolated git worktree at ${worktreeCwd} — same repository, same relative file structure, separate working copy. Paths in the inherited context refer to the parent's working directory; translate them to your worktree root. Re-read files before editing if the parent may have modified them since they appear in the context. Your changes stay in this worktree and will not affect the parent's files.`
|
||||
}
|
||||
755
packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts
Normal file
755
packages/builtin-tools/src/tools/AgentTool/loadAgentsDir.ts
Normal file
@@ -0,0 +1,755 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import memoize from 'lodash-es/memoize.js'
|
||||
import { basename } from 'path'
|
||||
import type { SettingSource } from 'src/utils/settings/constants.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { isAutoMemoryEnabled } from 'src/memdir/paths.js'
|
||||
import {
|
||||
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
logEvent,
|
||||
} from 'src/services/analytics/index.js'
|
||||
import {
|
||||
type McpServerConfig,
|
||||
McpServerConfigSchema,
|
||||
} from 'src/services/mcp/types.js'
|
||||
import type { ToolUseContext } from 'src/Tool.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import {
|
||||
EFFORT_LEVELS,
|
||||
type EffortValue,
|
||||
parseEffortValue,
|
||||
} from 'src/utils/effort.js'
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { parsePositiveIntFromFrontmatter } from 'src/utils/frontmatterParser.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import { logError } from 'src/utils/log.js'
|
||||
import {
|
||||
loadMarkdownFilesForSubdir,
|
||||
parseAgentToolsFromFrontmatter,
|
||||
parseSlashCommandToolsFromFrontmatter,
|
||||
} from 'src/utils/markdownConfigLoader.js'
|
||||
import {
|
||||
PERMISSION_MODES,
|
||||
type PermissionMode,
|
||||
} from 'src/utils/permissions/PermissionMode.js'
|
||||
import {
|
||||
clearPluginAgentCache,
|
||||
loadPluginAgents,
|
||||
} from 'src/utils/plugins/loadPluginAgents.js'
|
||||
import { HooksSchema, type HooksSettings } from 'src/utils/settings/types.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import {
|
||||
AGENT_COLORS,
|
||||
type AgentColorName,
|
||||
setAgentColor,
|
||||
} from './agentColorManager.js'
|
||||
import { type AgentMemoryScope, loadAgentMemoryPrompt } from './agentMemory.js'
|
||||
import {
|
||||
checkAgentMemorySnapshot,
|
||||
initializeFromSnapshot,
|
||||
} from './agentMemorySnapshot.js'
|
||||
import { getBuiltInAgents } from './builtInAgents.js'
|
||||
|
||||
// Type for MCP server specification in agent definitions
|
||||
// Can be either a reference to an existing server by name, or an inline definition as { [name]: config }
|
||||
export type AgentMcpServerSpec =
|
||||
| string // Reference to existing server by name (e.g., "slack")
|
||||
| { [name: string]: McpServerConfig } // Inline definition as { name: config }
|
||||
|
||||
// Zod schema for agent MCP server specs
|
||||
const AgentMcpServerSpecSchema = lazySchema(() =>
|
||||
z.union([
|
||||
z.string(), // Reference by name
|
||||
z.record(z.string(), McpServerConfigSchema()), // Inline as { name: config }
|
||||
]),
|
||||
)
|
||||
|
||||
// Zod schemas for JSON agent validation
|
||||
// Note: HooksSchema is lazy so the circular chain AppState -> loadAgentsDir -> settings/types
|
||||
// is broken at module load time
|
||||
const AgentJsonSchema = lazySchema(() =>
|
||||
z.object({
|
||||
description: z.string().min(1, 'Description cannot be empty'),
|
||||
tools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
prompt: z.string().min(1, 'Prompt cannot be empty'),
|
||||
model: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Model cannot be empty')
|
||||
.transform(m => (m.toLowerCase() === 'inherit' ? 'inherit' : m))
|
||||
.optional(),
|
||||
effort: z.union([z.enum(EFFORT_LEVELS), z.number().int()]).optional(),
|
||||
permissionMode: z.enum(PERMISSION_MODES).optional(),
|
||||
mcpServers: z.array(AgentMcpServerSpecSchema()).optional(),
|
||||
hooks: HooksSchema().optional(),
|
||||
maxTurns: z.number().int().positive().optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
initialPrompt: z.string().optional(),
|
||||
memory: z.enum(['user', 'project', 'local']).optional(),
|
||||
background: z.boolean().optional(),
|
||||
isolation: (process.env.USER_TYPE === 'ant'
|
||||
? z.enum(['worktree', 'remote'])
|
||||
: z.enum(['worktree'])
|
||||
).optional(),
|
||||
}),
|
||||
)
|
||||
|
||||
const AgentsJsonSchema = lazySchema(() =>
|
||||
z.record(z.string(), AgentJsonSchema()),
|
||||
)
|
||||
|
||||
// Base type with common fields for all agents
|
||||
export type BaseAgentDefinition = {
|
||||
agentType: string
|
||||
whenToUse: string
|
||||
tools?: string[]
|
||||
disallowedTools?: string[]
|
||||
skills?: string[] // Skill names to preload (parsed from comma-separated frontmatter)
|
||||
mcpServers?: AgentMcpServerSpec[] // MCP servers specific to this agent
|
||||
hooks?: HooksSettings // Session-scoped hooks registered when agent starts
|
||||
color?: AgentColorName
|
||||
model?: string
|
||||
effort?: EffortValue
|
||||
permissionMode?: PermissionMode
|
||||
maxTurns?: number // Maximum number of agentic turns before stopping
|
||||
filename?: string // Original filename without .md extension (for user/project/managed agents)
|
||||
baseDir?: string
|
||||
criticalSystemReminder_EXPERIMENTAL?: string // Short message re-injected at every user turn
|
||||
requiredMcpServers?: string[] // MCP server name patterns that must be configured for agent to be available
|
||||
background?: boolean // Always run as background task when spawned
|
||||
initialPrompt?: string // Prepended to the first user turn (slash commands work)
|
||||
memory?: AgentMemoryScope // Persistent memory scope
|
||||
isolation?: 'worktree' | 'remote' // Run in an isolated git worktree, or remotely in CCR (ant-only)
|
||||
pendingSnapshotUpdate?: { snapshotTimestamp: string }
|
||||
/** Omit CLAUDE.md hierarchy from the agent's userContext. Read-only agents
|
||||
* (Explore, Plan) don't need commit/PR/lint guidelines — the main agent has
|
||||
* full CLAUDE.md and interprets their output. Saves ~5-15 Gtok/week across
|
||||
* 34M+ Explore spawns. Kill-switch: tengu_slim_subagent_claudemd. */
|
||||
omitClaudeMd?: boolean
|
||||
}
|
||||
|
||||
// Built-in agents - dynamic prompts only, no static systemPrompt field
|
||||
export type BuiltInAgentDefinition = BaseAgentDefinition & {
|
||||
source: 'built-in'
|
||||
baseDir: 'built-in'
|
||||
callback?: () => void
|
||||
getSystemPrompt: (params: {
|
||||
toolUseContext: Pick<ToolUseContext, 'options'>
|
||||
}) => string
|
||||
}
|
||||
|
||||
// Custom agents from user/project/policy settings - prompt stored via closure
|
||||
export type CustomAgentDefinition = BaseAgentDefinition & {
|
||||
getSystemPrompt: () => string
|
||||
source: SettingSource
|
||||
filename?: string
|
||||
baseDir?: string
|
||||
}
|
||||
|
||||
// Plugin agents - similar to custom but with plugin metadata, prompt stored via closure
|
||||
export type PluginAgentDefinition = BaseAgentDefinition & {
|
||||
getSystemPrompt: () => string
|
||||
source: 'plugin'
|
||||
filename?: string
|
||||
plugin: string
|
||||
}
|
||||
|
||||
// Union type for all agent types
|
||||
export type AgentDefinition =
|
||||
| BuiltInAgentDefinition
|
||||
| CustomAgentDefinition
|
||||
| PluginAgentDefinition
|
||||
|
||||
// Type guards for runtime type checking
|
||||
export function isBuiltInAgent(
|
||||
agent: AgentDefinition,
|
||||
): agent is BuiltInAgentDefinition {
|
||||
return agent.source === 'built-in'
|
||||
}
|
||||
|
||||
export function isCustomAgent(
|
||||
agent: AgentDefinition,
|
||||
): agent is CustomAgentDefinition {
|
||||
return agent.source !== 'built-in' && agent.source !== 'plugin'
|
||||
}
|
||||
|
||||
export function isPluginAgent(
|
||||
agent: AgentDefinition,
|
||||
): agent is PluginAgentDefinition {
|
||||
return agent.source === 'plugin'
|
||||
}
|
||||
|
||||
export type AgentDefinitionsResult = {
|
||||
activeAgents: AgentDefinition[]
|
||||
allAgents: AgentDefinition[]
|
||||
failedFiles?: Array<{ path: string; error: string }>
|
||||
allowedAgentTypes?: string[]
|
||||
}
|
||||
|
||||
export function getActiveAgentsFromList(
|
||||
allAgents: AgentDefinition[],
|
||||
): AgentDefinition[] {
|
||||
const builtInAgents = allAgents.filter(a => a.source === 'built-in')
|
||||
const pluginAgents = allAgents.filter(a => a.source === 'plugin')
|
||||
const userAgents = allAgents.filter(a => a.source === 'userSettings')
|
||||
const projectAgents = allAgents.filter(a => a.source === 'projectSettings')
|
||||
const managedAgents = allAgents.filter(a => a.source === 'policySettings')
|
||||
const flagAgents = allAgents.filter(a => a.source === 'flagSettings')
|
||||
|
||||
const agentGroups = [
|
||||
builtInAgents,
|
||||
pluginAgents,
|
||||
userAgents,
|
||||
projectAgents,
|
||||
flagAgents,
|
||||
managedAgents,
|
||||
]
|
||||
|
||||
const agentMap = new Map<string, AgentDefinition>()
|
||||
|
||||
for (const agents of agentGroups) {
|
||||
for (const agent of agents) {
|
||||
agentMap.set(agent.agentType, agent)
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(agentMap.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an agent's required MCP servers are available.
|
||||
* Returns true if no requirements or all requirements are met.
|
||||
* @param agent The agent to check
|
||||
* @param availableServers List of available MCP server names (e.g., from mcp.clients)
|
||||
*/
|
||||
export function hasRequiredMcpServers(
|
||||
agent: AgentDefinition,
|
||||
availableServers: string[],
|
||||
): boolean {
|
||||
if (!agent.requiredMcpServers || agent.requiredMcpServers.length === 0) {
|
||||
return true
|
||||
}
|
||||
// Each required pattern must match at least one available server (case-insensitive)
|
||||
return agent.requiredMcpServers.every(pattern =>
|
||||
availableServers.some(server =>
|
||||
server.toLowerCase().includes(pattern.toLowerCase()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters agents based on MCP server requirements.
|
||||
* Only returns agents whose required MCP servers are available.
|
||||
* @param agents List of agents to filter
|
||||
* @param availableServers List of available MCP server names
|
||||
*/
|
||||
export function filterAgentsByMcpRequirements(
|
||||
agents: AgentDefinition[],
|
||||
availableServers: string[],
|
||||
): AgentDefinition[] {
|
||||
return agents.filter(agent => hasRequiredMcpServers(agent, availableServers))
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for and initialize agent memory from project snapshots.
|
||||
* For agents with memory enabled, copies snapshot to local if no local memory exists.
|
||||
* For agents with newer snapshots, logs a debug message (user prompt TODO).
|
||||
*/
|
||||
async function initializeAgentMemorySnapshots(
|
||||
agents: CustomAgentDefinition[],
|
||||
): Promise<void> {
|
||||
await Promise.all(
|
||||
agents.map(async agent => {
|
||||
if (agent.memory !== 'user') return
|
||||
const result = await checkAgentMemorySnapshot(
|
||||
agent.agentType,
|
||||
agent.memory,
|
||||
)
|
||||
switch (result.action) {
|
||||
case 'initialize':
|
||||
logForDebugging(
|
||||
`Initializing ${agent.agentType} memory from project snapshot`,
|
||||
)
|
||||
await initializeFromSnapshot(
|
||||
agent.agentType,
|
||||
agent.memory,
|
||||
result.snapshotTimestamp!,
|
||||
)
|
||||
break
|
||||
case 'prompt-update':
|
||||
agent.pendingSnapshotUpdate = {
|
||||
snapshotTimestamp: result.snapshotTimestamp!,
|
||||
}
|
||||
logForDebugging(
|
||||
`Newer snapshot available for ${agent.agentType} memory (snapshot: ${result.snapshotTimestamp})`,
|
||||
)
|
||||
break
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const getAgentDefinitionsWithOverrides = memoize(
|
||||
async (cwd: string): Promise<AgentDefinitionsResult> => {
|
||||
// Simple mode: skip custom agents, only return built-ins
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
||||
const builtInAgents = getBuiltInAgents()
|
||||
return {
|
||||
activeAgents: builtInAgents,
|
||||
allAgents: builtInAgents,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const markdownFiles = await loadMarkdownFilesForSubdir('agents', cwd)
|
||||
|
||||
const failedFiles: Array<{ path: string; error: string }> = []
|
||||
const customAgents = markdownFiles
|
||||
.map(({ filePath, baseDir, frontmatter, content, source }) => {
|
||||
const agent = parseAgentFromMarkdown(
|
||||
filePath,
|
||||
baseDir,
|
||||
frontmatter,
|
||||
content,
|
||||
source,
|
||||
)
|
||||
if (!agent) {
|
||||
// Skip non-agent markdown files silently (e.g., reference docs
|
||||
// co-located with agent definitions). Only report errors for files
|
||||
// that look like agent attempts (have a 'name' field in frontmatter).
|
||||
if (!frontmatter['name']) {
|
||||
return null
|
||||
}
|
||||
const errorMsg = getParseError(frontmatter)
|
||||
failedFiles.push({ path: filePath, error: errorMsg })
|
||||
logForDebugging(
|
||||
`Failed to parse agent from ${filePath}: ${errorMsg}`,
|
||||
)
|
||||
logEvent('tengu_agent_parse_error', {
|
||||
error:
|
||||
errorMsg as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
location:
|
||||
source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
return null
|
||||
}
|
||||
return agent
|
||||
})
|
||||
.filter(agent => agent !== null)
|
||||
|
||||
// Kick off plugin agent loading concurrently with memory snapshot init —
|
||||
// loadPluginAgents is memoized and takes no args, so it's independent.
|
||||
// Join both so neither becomes a floating promise if the other throws.
|
||||
let pluginAgentsPromise = loadPluginAgents()
|
||||
if (feature('AGENT_MEMORY_SNAPSHOT') && isAutoMemoryEnabled()) {
|
||||
const [pluginAgents_] = await Promise.all([
|
||||
pluginAgentsPromise,
|
||||
initializeAgentMemorySnapshots(customAgents),
|
||||
])
|
||||
pluginAgentsPromise = Promise.resolve(pluginAgents_)
|
||||
}
|
||||
const pluginAgents = await pluginAgentsPromise
|
||||
|
||||
const builtInAgents = getBuiltInAgents()
|
||||
|
||||
const allAgentsList: AgentDefinition[] = [
|
||||
...builtInAgents,
|
||||
...pluginAgents,
|
||||
...customAgents,
|
||||
]
|
||||
|
||||
const activeAgents = getActiveAgentsFromList(allAgentsList)
|
||||
|
||||
// Initialize colors for all active agents
|
||||
for (const agent of activeAgents) {
|
||||
if (agent.color) {
|
||||
setAgentColor(agent.agentType, agent.color)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
activeAgents,
|
||||
allAgents: allAgentsList,
|
||||
failedFiles: failedFiles.length > 0 ? failedFiles : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error loading agent definitions: ${errorMessage}`)
|
||||
logError(error)
|
||||
// Even on error, return the built-in agents
|
||||
const builtInAgents = getBuiltInAgents()
|
||||
return {
|
||||
activeAgents: builtInAgents,
|
||||
allAgents: builtInAgents,
|
||||
failedFiles: [{ path: 'unknown', error: errorMessage }],
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export function clearAgentDefinitionsCache(): void {
|
||||
getAgentDefinitionsWithOverrides.cache.clear?.()
|
||||
clearPluginAgentCache()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to determine the specific parsing error for an agent file
|
||||
*/
|
||||
function getParseError(frontmatter: Record<string, unknown>): string {
|
||||
const agentType = frontmatter['name']
|
||||
const description = frontmatter['description']
|
||||
|
||||
if (!agentType || typeof agentType !== 'string') {
|
||||
return 'Missing required "name" field in frontmatter'
|
||||
}
|
||||
|
||||
if (!description || typeof description !== 'string') {
|
||||
return 'Missing required "description" field in frontmatter'
|
||||
}
|
||||
|
||||
return 'Unknown parsing error'
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse hooks from frontmatter using the HooksSchema
|
||||
* @param frontmatter The frontmatter object containing potential hooks
|
||||
* @param agentType The agent type for logging purposes
|
||||
* @returns Parsed hooks settings or undefined if invalid/missing
|
||||
*/
|
||||
function parseHooksFromFrontmatter(
|
||||
frontmatter: Record<string, unknown>,
|
||||
agentType: string,
|
||||
): HooksSettings | undefined {
|
||||
if (!frontmatter.hooks) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const result = HooksSchema().safeParse(frontmatter.hooks)
|
||||
if (!result.success) {
|
||||
logForDebugging(
|
||||
`Invalid hooks in agent '${agentType}': ${result.error.message}`,
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses agent definition from JSON data
|
||||
*/
|
||||
export function parseAgentFromJson(
|
||||
name: string,
|
||||
definition: unknown,
|
||||
source: SettingSource = 'flagSettings',
|
||||
): CustomAgentDefinition | null {
|
||||
try {
|
||||
const parsed = AgentJsonSchema().parse(definition)
|
||||
|
||||
let tools = parseAgentToolsFromFrontmatter(parsed.tools)
|
||||
|
||||
// If memory is enabled, inject Write/Edit/Read tools for memory access
|
||||
if (isAutoMemoryEnabled() && parsed.memory && tools !== undefined) {
|
||||
const toolSet = new Set(tools)
|
||||
for (const tool of [
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
]) {
|
||||
if (!toolSet.has(tool)) {
|
||||
tools = [...tools, tool]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const disallowedTools =
|
||||
parsed.disallowedTools !== undefined
|
||||
? parseAgentToolsFromFrontmatter(parsed.disallowedTools)
|
||||
: undefined
|
||||
|
||||
const systemPrompt = parsed.prompt
|
||||
|
||||
const agent: CustomAgentDefinition = {
|
||||
agentType: name,
|
||||
whenToUse: parsed.description,
|
||||
...(tools !== undefined ? { tools } : {}),
|
||||
...(disallowedTools !== undefined ? { disallowedTools } : {}),
|
||||
getSystemPrompt: () => {
|
||||
if (isAutoMemoryEnabled() && parsed.memory) {
|
||||
return (
|
||||
systemPrompt + '\n\n' + loadAgentMemoryPrompt(name, parsed.memory)
|
||||
)
|
||||
}
|
||||
return systemPrompt
|
||||
},
|
||||
source,
|
||||
...(parsed.model ? { model: parsed.model } : {}),
|
||||
...(parsed.effort !== undefined ? { effort: parsed.effort } : {}),
|
||||
...(parsed.permissionMode
|
||||
? { permissionMode: parsed.permissionMode }
|
||||
: {}),
|
||||
...(parsed.mcpServers && parsed.mcpServers.length > 0
|
||||
? { mcpServers: parsed.mcpServers }
|
||||
: {}),
|
||||
...(parsed.hooks ? { hooks: parsed.hooks } : {}),
|
||||
...(parsed.maxTurns !== undefined ? { maxTurns: parsed.maxTurns } : {}),
|
||||
...(parsed.skills && parsed.skills.length > 0
|
||||
? { skills: parsed.skills }
|
||||
: {}),
|
||||
...(parsed.initialPrompt ? { initialPrompt: parsed.initialPrompt } : {}),
|
||||
...(parsed.background ? { background: parsed.background } : {}),
|
||||
...(parsed.memory ? { memory: parsed.memory } : {}),
|
||||
...(parsed.isolation ? { isolation: parsed.isolation } : {}),
|
||||
}
|
||||
|
||||
return agent
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error parsing agent '${name}' from JSON: ${errorMessage}`)
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses multiple agents from a JSON object
|
||||
*/
|
||||
export function parseAgentsFromJson(
|
||||
agentsJson: unknown,
|
||||
source: SettingSource = 'flagSettings',
|
||||
): AgentDefinition[] {
|
||||
try {
|
||||
const parsed = AgentsJsonSchema().parse(agentsJson)
|
||||
return Object.entries(parsed)
|
||||
.map(([name, def]) => parseAgentFromJson(name, def, source))
|
||||
.filter((agent): agent is CustomAgentDefinition => agent !== null)
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error parsing agents from JSON: ${errorMessage}`)
|
||||
logError(error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses agent definition from markdown file data
|
||||
*/
|
||||
export function parseAgentFromMarkdown(
|
||||
filePath: string,
|
||||
baseDir: string,
|
||||
frontmatter: Record<string, unknown>,
|
||||
content: string,
|
||||
source: SettingSource,
|
||||
): CustomAgentDefinition | null {
|
||||
try {
|
||||
const agentType = frontmatter['name']
|
||||
let whenToUse = frontmatter['description'] as string
|
||||
|
||||
// Validate required fields — silently skip files without any agent
|
||||
// frontmatter (they're likely co-located reference documentation)
|
||||
if (!agentType || typeof agentType !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (!whenToUse || typeof whenToUse !== 'string') {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} is missing required 'description' in frontmatter`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
// Unescape newlines in whenToUse that were escaped for YAML parsing
|
||||
whenToUse = whenToUse.replace(/\\n/g, '\n')
|
||||
|
||||
const color = frontmatter['color'] as AgentColorName | undefined
|
||||
const modelRaw = frontmatter['model']
|
||||
let model: string | undefined
|
||||
if (typeof modelRaw === 'string' && modelRaw.trim().length > 0) {
|
||||
const trimmed = modelRaw.trim()
|
||||
model = trimmed.toLowerCase() === 'inherit' ? 'inherit' : trimmed
|
||||
}
|
||||
|
||||
// Parse background flag
|
||||
const backgroundRaw = frontmatter['background']
|
||||
|
||||
if (
|
||||
backgroundRaw !== undefined &&
|
||||
backgroundRaw !== 'true' &&
|
||||
backgroundRaw !== 'false' &&
|
||||
backgroundRaw !== true &&
|
||||
backgroundRaw !== false
|
||||
) {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`,
|
||||
)
|
||||
}
|
||||
|
||||
const background =
|
||||
backgroundRaw === 'true' || backgroundRaw === true ? true : undefined
|
||||
|
||||
// Parse memory scope
|
||||
const VALID_MEMORY_SCOPES: AgentMemoryScope[] = ['user', 'project', 'local']
|
||||
const memoryRaw = frontmatter['memory'] as string | undefined
|
||||
let memory: AgentMemoryScope | undefined
|
||||
if (memoryRaw !== undefined) {
|
||||
if (VALID_MEMORY_SCOPES.includes(memoryRaw as AgentMemoryScope)) {
|
||||
memory = memoryRaw as AgentMemoryScope
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid memory value '${memoryRaw}'. Valid options: ${VALID_MEMORY_SCOPES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse isolation mode. 'remote' is ant-only; external builds reject it at parse time.
|
||||
type IsolationMode = 'worktree' | 'remote'
|
||||
const VALID_ISOLATION_MODES: readonly IsolationMode[] =
|
||||
process.env.USER_TYPE === 'ant' ? ['worktree', 'remote'] : ['worktree']
|
||||
const isolationRaw = frontmatter['isolation'] as string | undefined
|
||||
let isolation: IsolationMode | undefined
|
||||
if (isolationRaw !== undefined) {
|
||||
if (VALID_ISOLATION_MODES.includes(isolationRaw as IsolationMode)) {
|
||||
isolation = isolationRaw as IsolationMode
|
||||
} else {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid isolation value '${isolationRaw}'. Valid options: ${VALID_ISOLATION_MODES.join(', ')}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse effort from frontmatter (supports string levels and integers)
|
||||
const effortRaw = frontmatter['effort']
|
||||
const parsedEffort =
|
||||
effortRaw !== undefined ? parseEffortValue(effortRaw) : undefined
|
||||
|
||||
if (effortRaw !== undefined && parsedEffort === undefined) {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid effort '${effortRaw}'. Valid options: ${EFFORT_LEVELS.join(', ')} or an integer`,
|
||||
)
|
||||
}
|
||||
|
||||
// Parse permissionMode from frontmatter
|
||||
const permissionModeRaw = frontmatter['permissionMode'] as
|
||||
| string
|
||||
| undefined
|
||||
const isValidPermissionMode =
|
||||
permissionModeRaw &&
|
||||
(PERMISSION_MODES as readonly string[]).includes(permissionModeRaw)
|
||||
|
||||
if (permissionModeRaw && !isValidPermissionMode) {
|
||||
const errorMsg = `Agent file ${filePath} has invalid permissionMode '${permissionModeRaw}'. Valid options: ${PERMISSION_MODES.join(', ')}`
|
||||
logForDebugging(errorMsg)
|
||||
}
|
||||
|
||||
// Parse maxTurns from frontmatter
|
||||
const maxTurnsRaw = frontmatter['maxTurns']
|
||||
const maxTurns = parsePositiveIntFromFrontmatter(maxTurnsRaw)
|
||||
if (maxTurnsRaw !== undefined && maxTurns === undefined) {
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid maxTurns '${maxTurnsRaw}'. Must be a positive integer.`,
|
||||
)
|
||||
}
|
||||
|
||||
// Extract filename without extension
|
||||
const filename = basename(filePath, '.md')
|
||||
|
||||
// Parse tools from frontmatter
|
||||
let tools = parseAgentToolsFromFrontmatter(frontmatter['tools'])
|
||||
|
||||
// If memory is enabled, inject Write/Edit/Read tools for memory access
|
||||
if (isAutoMemoryEnabled() && memory && tools !== undefined) {
|
||||
const toolSet = new Set(tools)
|
||||
for (const tool of [
|
||||
FILE_WRITE_TOOL_NAME,
|
||||
FILE_EDIT_TOOL_NAME,
|
||||
FILE_READ_TOOL_NAME,
|
||||
]) {
|
||||
if (!toolSet.has(tool)) {
|
||||
tools = [...tools, tool]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse disallowedTools from frontmatter
|
||||
const disallowedToolsRaw = frontmatter['disallowedTools']
|
||||
const disallowedTools =
|
||||
disallowedToolsRaw !== undefined
|
||||
? parseAgentToolsFromFrontmatter(disallowedToolsRaw)
|
||||
: undefined
|
||||
|
||||
// Parse skills from frontmatter
|
||||
const skills = parseSlashCommandToolsFromFrontmatter(frontmatter['skills'])
|
||||
|
||||
const initialPromptRaw = frontmatter['initialPrompt']
|
||||
const initialPrompt =
|
||||
typeof initialPromptRaw === 'string' && initialPromptRaw.trim()
|
||||
? initialPromptRaw
|
||||
: undefined
|
||||
|
||||
// Parse mcpServers from frontmatter using same Zod validation as JSON agents
|
||||
const mcpServersRaw = frontmatter['mcpServers']
|
||||
let mcpServers: AgentMcpServerSpec[] | undefined
|
||||
if (Array.isArray(mcpServersRaw)) {
|
||||
mcpServers = mcpServersRaw
|
||||
.map(item => {
|
||||
const result = AgentMcpServerSpecSchema().safeParse(item)
|
||||
if (result.success) {
|
||||
return result.data
|
||||
}
|
||||
logForDebugging(
|
||||
`Agent file ${filePath} has invalid mcpServers item: ${jsonStringify(item)}. Error: ${result.error.message}`,
|
||||
)
|
||||
return null
|
||||
})
|
||||
.filter((item): item is AgentMcpServerSpec => item !== null)
|
||||
}
|
||||
|
||||
// Parse hooks from frontmatter
|
||||
const hooks = parseHooksFromFrontmatter(frontmatter, agentType)
|
||||
|
||||
const systemPrompt = content.trim()
|
||||
const agentDef: CustomAgentDefinition = {
|
||||
baseDir,
|
||||
agentType: agentType,
|
||||
whenToUse: whenToUse,
|
||||
...(tools !== undefined ? { tools } : {}),
|
||||
...(disallowedTools !== undefined ? { disallowedTools } : {}),
|
||||
...(skills !== undefined ? { skills } : {}),
|
||||
...(initialPrompt !== undefined ? { initialPrompt } : {}),
|
||||
...(mcpServers !== undefined && mcpServers.length > 0
|
||||
? { mcpServers }
|
||||
: {}),
|
||||
...(hooks !== undefined ? { hooks } : {}),
|
||||
getSystemPrompt: () => {
|
||||
if (isAutoMemoryEnabled() && memory) {
|
||||
const memoryPrompt = loadAgentMemoryPrompt(agentType, memory)
|
||||
return systemPrompt + '\n\n' + memoryPrompt
|
||||
}
|
||||
return systemPrompt
|
||||
},
|
||||
source,
|
||||
filename,
|
||||
...(color && typeof color === 'string' && AGENT_COLORS.includes(color)
|
||||
? { color }
|
||||
: {}),
|
||||
...(model !== undefined ? { model } : {}),
|
||||
...(parsedEffort !== undefined ? { effort: parsedEffort } : {}),
|
||||
...(isValidPermissionMode
|
||||
? { permissionMode: permissionModeRaw as PermissionMode }
|
||||
: {}),
|
||||
...(maxTurns !== undefined ? { maxTurns } : {}),
|
||||
...(background ? { background } : {}),
|
||||
...(memory ? { memory } : {}),
|
||||
...(isolation ? { isolation } : {}),
|
||||
}
|
||||
return agentDef
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
logForDebugging(`Error parsing agent from ${filePath}: ${errorMessage}`)
|
||||
logError(error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
287
packages/builtin-tools/src/tools/AgentTool/prompt.ts
Normal file
287
packages/builtin-tools/src/tools/AgentTool/prompt.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { getSubscriptionType } from 'src/utils/auth.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { isEnvDefinedFalsy, isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { isTeammate } from 'src/utils/teammate.js'
|
||||
import { isInProcessTeammate } from 'src/utils/teammateContext.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { SEND_MESSAGE_TOOL_NAME } from '../SendMessageTool/constants.js'
|
||||
import { AGENT_TOOL_NAME } from './constants.js'
|
||||
import { isForkSubagentEnabled } from './forkSubagent.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
|
||||
function getToolsDescription(agent: AgentDefinition): string {
|
||||
const { tools, disallowedTools } = agent
|
||||
const hasAllowlist = tools && tools.length > 0
|
||||
const hasDenylist = disallowedTools && disallowedTools.length > 0
|
||||
|
||||
if (hasAllowlist && hasDenylist) {
|
||||
// Both defined: filter allowlist by denylist to match runtime behavior
|
||||
const denySet = new Set(disallowedTools)
|
||||
const effectiveTools = tools.filter(t => !denySet.has(t))
|
||||
if (effectiveTools.length === 0) {
|
||||
return 'None'
|
||||
}
|
||||
return effectiveTools.join(', ')
|
||||
} else if (hasAllowlist) {
|
||||
// Allowlist only: show the specific tools available
|
||||
return tools.join(', ')
|
||||
} else if (hasDenylist) {
|
||||
// Denylist only: show "All tools except X, Y, Z"
|
||||
return `All tools except ${disallowedTools.join(', ')}`
|
||||
}
|
||||
// No restrictions
|
||||
return 'All tools'
|
||||
}
|
||||
|
||||
/**
|
||||
* Format one agent line for the agent_listing_delta attachment message:
|
||||
* `- type: whenToUse (Tools: ...)`.
|
||||
*/
|
||||
export function formatAgentLine(agent: AgentDefinition): string {
|
||||
const toolsDescription = getToolsDescription(agent)
|
||||
return `- ${agent.agentType}: ${agent.whenToUse} (Tools: ${toolsDescription})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the agent list should be injected as an attachment message instead
|
||||
* of embedded in the tool description. When true, getPrompt() returns a static
|
||||
* description and attachments.ts emits an agent_listing_delta attachment.
|
||||
*
|
||||
* The dynamic agent list was ~10.2% of fleet cache_creation tokens: MCP async
|
||||
* connect, /reload-plugins, or permission-mode changes mutate the list →
|
||||
* description changes → full tool-schema cache bust.
|
||||
*
|
||||
* Override with CLAUDE_CODE_AGENT_LIST_IN_MESSAGES=true/false for testing.
|
||||
*/
|
||||
export function shouldInjectAgentListInMessages(): boolean {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES)) return true
|
||||
if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_AGENT_LIST_IN_MESSAGES))
|
||||
return false
|
||||
return getFeatureValue_CACHED_MAY_BE_STALE('tengu_agent_list_attach', false)
|
||||
}
|
||||
|
||||
export async function getPrompt(
|
||||
agentDefinitions: AgentDefinition[],
|
||||
isCoordinator?: boolean,
|
||||
allowedAgentTypes?: string[],
|
||||
): Promise<string> {
|
||||
// Filter agents by allowed types when Agent(x,y) restricts which agents can be spawned
|
||||
const effectiveAgents = allowedAgentTypes
|
||||
? agentDefinitions.filter(a => allowedAgentTypes.includes(a.agentType))
|
||||
: agentDefinitions
|
||||
|
||||
// Fork subagent feature: when enabled, insert the "When to fork" section
|
||||
// (fork semantics, directive-style prompts) and swap in fork-aware examples.
|
||||
const forkEnabled = isForkSubagentEnabled()
|
||||
|
||||
const whenToForkSection = forkEnabled
|
||||
? `
|
||||
|
||||
## When to fork
|
||||
|
||||
Fork yourself (omit \`subagent_type\`) when the intermediate tool output isn't worth keeping in your context. The criterion is qualitative \u2014 "will I need this output again" \u2014 not task size.
|
||||
- **Research**: fork open-ended questions. If research can be broken into independent questions, launch parallel forks in one message. A fork beats a fresh subagent for this \u2014 it inherits context and shares your cache.
|
||||
- **Implementation**: prefer to fork implementation work that requires more than a couple of edits. Do research before jumping to implementation.
|
||||
|
||||
Forks are cheap because they share your prompt cache. Don't set \`model\` on a fork \u2014 a different model can't reuse the parent's cache. Pass a short \`name\` (one or two words, lowercase) so the user can see the fork in the teams panel and steer it mid-run.
|
||||
|
||||
**Don't peek.** The tool result includes an \`output_file\` path — do not Read or tail it unless the user explicitly asks for a progress check. You get a completion notification; trust it. Reading the transcript mid-flight pulls the fork's tool noise into your context, which defeats the point of forking.
|
||||
|
||||
**Don't race.** After launching, you know nothing about what the fork found. Never fabricate or predict fork results in any format — not as prose, summary, or structured output. The notification arrives as a user-role message in a later turn; it is never something you write yourself. If the user asks a follow-up before the notification lands, tell them the fork is still running — give status, not a guess.
|
||||
|
||||
**Writing a fork prompt.** Since the fork inherits your context, the prompt is a *directive* — what to do, not what the situation is. Be specific about scope: what's in, what's out, what another agent is handling. Don't re-explain background.
|
||||
`
|
||||
: ''
|
||||
|
||||
const writingThePromptSection = `
|
||||
|
||||
## Writing the prompt
|
||||
|
||||
${forkEnabled ? 'When spawning a fresh agent (with a `subagent_type`), it starts with zero context. ' : ''}Brief the agent like a smart colleague who just walked into the room — it hasn't seen this conversation, doesn't know what you've tried, doesn't understand why this task matters.
|
||||
- Explain what you're trying to accomplish and why.
|
||||
- Describe what you've already learned or ruled out.
|
||||
- Give enough context about the surrounding problem that the agent can make judgment calls rather than just following a narrow instruction.
|
||||
- If you need a short response, say so ("report in under 200 words").
|
||||
- Lookups: hand over the exact command. Investigations: hand over the question — prescribed steps become dead weight when the premise is wrong.
|
||||
|
||||
${forkEnabled ? 'For fresh agents, terse' : 'Terse'} command-style prompts produce shallow, generic work.
|
||||
|
||||
**Never delegate understanding.** Don't write "based on your findings, fix the bug" or "based on the research, implement it." Those phrases push synthesis onto the agent instead of doing it yourself. Write prompts that prove you understood: include file paths, line numbers, what specifically to change.
|
||||
`
|
||||
|
||||
const forkExamples = `Example usage:
|
||||
|
||||
<example>
|
||||
user: "What's left on this branch before we can ship?"
|
||||
assistant: <thinking>Forking this \u2014 it's a survey question. I want the punch list, not the git output in my context.</thinking>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "ship-audit",
|
||||
description: "Branch ship-readiness audit",
|
||||
prompt: "Audit what's left before this branch can ship. Check: uncommitted changes, commits ahead of main, whether tests exist, whether the GrowthBook gate is wired up, whether CI-relevant files changed. Report a punch list \u2014 done vs. missing. Under 200 words."
|
||||
})
|
||||
assistant: Ship-readiness audit running.
|
||||
<commentary>
|
||||
Turn ends here. The coordinator knows nothing about the findings yet. What follows is a SEPARATE turn \u2014 the notification arrives from outside, as a user-role message. It is not something the coordinator writes.
|
||||
</commentary>
|
||||
[later turn \u2014 notification arrives as user message]
|
||||
assistant: Audit's back. Three blockers: no tests for the new prompt path, GrowthBook gate wired but not in build_flags.yaml, and one uncommitted file.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "so is the gate wired up or not"
|
||||
<commentary>
|
||||
User asks mid-wait. The audit fork was launched to answer exactly this, and it hasn't returned. The coordinator does not have this answer. Give status, not a fabricated result.
|
||||
</commentary>
|
||||
assistant: Still waiting on the audit \u2014 that's one of the things it's checking. Should land shortly.
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Can you get a second opinion on whether this migration is safe?"
|
||||
assistant: <thinking>I'll ask the code-reviewer agent — it won't see my analysis, so it can give an independent read.</thinking>
|
||||
<commentary>
|
||||
A subagent_type is specified, so the agent starts fresh. It needs full context in the prompt. The briefing explains what to assess and why.
|
||||
</commentary>
|
||||
${AGENT_TOOL_NAME}({
|
||||
name: "migration-review",
|
||||
description: "Independent migration review",
|
||||
subagent_type: "code-reviewer",
|
||||
prompt: "Review migration 0042_user_schema.sql for safety. Context: we're adding a NOT NULL column to a 50M-row table. Existing rows get a backfill default. I want a second opinion on whether the backfill approach is safe under concurrent writes — I've checked locking behavior but want independent verification. Report: is this safe, and if not, what specifically breaks?"
|
||||
})
|
||||
</example>
|
||||
`
|
||||
|
||||
const currentExamples = `Example usage:
|
||||
|
||||
<example_agent_descriptions>
|
||||
"test-runner": use this agent after you are done writing code to run tests
|
||||
"greeting-responder": use this agent to respond to user greetings with a friendly joke
|
||||
</example_agent_descriptions>
|
||||
|
||||
<example>
|
||||
user: "Please write a function that checks if a number is prime"
|
||||
assistant: I'm going to use the ${FILE_WRITE_TOOL_NAME} tool to write the following code:
|
||||
<code>
|
||||
function isPrime(n) {
|
||||
if (n <= 1) return false
|
||||
for (let i = 2; i * i <= n; i++) {
|
||||
if (n % i === 0) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
</code>
|
||||
<commentary>
|
||||
Since a significant piece of code was written and the task was completed, now use the test-runner agent to run the tests
|
||||
</commentary>
|
||||
assistant: Uses the ${AGENT_TOOL_NAME} tool to launch the test-runner agent
|
||||
</example>
|
||||
|
||||
<example>
|
||||
user: "Hello"
|
||||
<commentary>
|
||||
Since the user is greeting, use the greeting-responder agent to respond with a friendly joke
|
||||
</commentary>
|
||||
assistant: "I'm going to use the ${AGENT_TOOL_NAME} tool to launch the greeting-responder agent"
|
||||
</example>
|
||||
`
|
||||
|
||||
// When the gate is on, the agent list lives in an agent_listing_delta
|
||||
// attachment (see attachments.ts) instead of inline here. This keeps the
|
||||
// tool description static across MCP/plugin/permission changes so the
|
||||
// tools-block prompt cache doesn't bust every time an agent loads.
|
||||
const listViaAttachment = shouldInjectAgentListInMessages()
|
||||
|
||||
const agentListSection = listViaAttachment
|
||||
? `Available agent types are listed in <system-reminder> messages in the conversation.`
|
||||
: `Available agent types and the tools they have access to:
|
||||
${effectiveAgents.map(agent => formatAgentLine(agent)).join('\n')}`
|
||||
|
||||
// Shared core prompt used by both coordinator and non-coordinator modes
|
||||
const shared = `Launch a new agent to handle complex, multi-step tasks autonomously.
|
||||
|
||||
The ${AGENT_TOOL_NAME} tool launches specialized agents (subprocesses) that autonomously handle complex tasks. Each agent type has specific capabilities and tools available to it.
|
||||
|
||||
${agentListSection}
|
||||
|
||||
${
|
||||
forkEnabled
|
||||
? `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type to use a specialized agent, or omit it to fork yourself — a fork inherits your full conversation context.`
|
||||
: `When using the ${AGENT_TOOL_NAME} tool, specify a subagent_type parameter to select which agent type to use. If omitted, the general-purpose agent is used.`
|
||||
}`
|
||||
|
||||
// Coordinator mode gets the slim prompt -- the coordinator system prompt
|
||||
// already covers usage notes, examples, and when-not-to-use guidance.
|
||||
if (isCoordinator) {
|
||||
return shared
|
||||
}
|
||||
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep and remove the
|
||||
// dedicated Glob/Grep tools, so point at find via Bash instead.
|
||||
const embedded = hasEmbeddedSearchTools()
|
||||
const fileSearchHint = embedded
|
||||
? '`find` via the Bash tool'
|
||||
: `the ${GLOB_TOOL_NAME} tool`
|
||||
// The "class Foo" example is about content search. Non-embedded stays Glob
|
||||
// (original intent: find-the-file-containing). Embedded gets grep because
|
||||
// find -name doesn't look at file contents.
|
||||
const contentSearchHint = embedded
|
||||
? '`grep` via the Bash tool'
|
||||
: `the ${GLOB_TOOL_NAME} tool`
|
||||
const whenNotToUseSection = forkEnabled
|
||||
? ''
|
||||
: `
|
||||
When NOT to use the ${AGENT_TOOL_NAME} tool:
|
||||
- If you want to read a specific file path, use the ${FILE_READ_TOOL_NAME} tool or ${fileSearchHint} instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly
|
||||
- If you are searching for a specific class definition like "class Foo", use ${contentSearchHint} instead, to find the match more quickly
|
||||
- If you are searching for code within a specific file or set of 2-3 files, use the ${FILE_READ_TOOL_NAME} tool instead of the ${AGENT_TOOL_NAME} tool, to find the match more quickly
|
||||
- Other tasks that are not related to the agent descriptions above
|
||||
`
|
||||
|
||||
// When listing via attachment, the "launch multiple agents" note is in the
|
||||
// attachment message (conditioned on subscription there). When inline, keep
|
||||
// the existing per-call getSubscriptionType() check.
|
||||
const concurrencyNote =
|
||||
!listViaAttachment && getSubscriptionType() !== 'pro'
|
||||
? `
|
||||
- Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses`
|
||||
: ''
|
||||
|
||||
// Non-coordinator gets the full prompt with all sections
|
||||
return `${shared}
|
||||
${whenNotToUseSection}
|
||||
|
||||
Usage notes:
|
||||
- Always include a short description (3-5 words) summarizing what the agent will do${concurrencyNote}
|
||||
- When the agent is done, it will return a single message back to you. The result returned by the agent is not visible to the user. To show the user the result, you should send a text message back to the user with a concise summary of the result.${
|
||||
// eslint-disable-next-line custom-rules/no-process-env-top-level
|
||||
!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) &&
|
||||
!isInProcessTeammate() &&
|
||||
!forkEnabled
|
||||
? `
|
||||
- You can optionally run agents in the background using the run_in_background parameter. When an agent runs in the background, you will be automatically notified when it completes — do NOT sleep, poll, or proactively check on its progress. Continue with other work or respond to the user instead.
|
||||
- **Foreground vs background**: Use foreground (default) when you need the agent's results before you can proceed — e.g., research agents whose findings inform your next steps. Use background when you have genuinely independent work to do in parallel.`
|
||||
: ''
|
||||
}
|
||||
- To continue a previously spawned agent, use ${SEND_MESSAGE_TOOL_NAME} with the agent's ID or name as the \`to\` field. The agent resumes with its full context preserved. ${forkEnabled ? 'Each fresh Agent invocation with a subagent_type starts without context — provide a complete task description.' : 'Each Agent invocation starts fresh — provide a complete task description.'}
|
||||
- The agent's outputs should generally be trusted
|
||||
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.)${forkEnabled ? '' : ", since it is not aware of the user's intent"}
|
||||
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
|
||||
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple ${AGENT_TOOL_NAME} tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
|
||||
- You can optionally set \`isolation: "worktree"\` to run the agent in a temporary git worktree, giving it an isolated copy of the repository. The worktree is automatically cleaned up if the agent makes no changes; if changes are made, the worktree path and branch are returned in the result.${
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? `\n- You can set \`isolation: "remote"\` to run the agent in a remote CCR environment. This is always a background task; you'll be notified when it completes. Use for long-running tasks that need a fresh sandbox.`
|
||||
: ''
|
||||
}${
|
||||
isInProcessTeammate()
|
||||
? `
|
||||
- The run_in_background, name, team_name, and mode parameters are not available in this context. Only synchronous subagents are supported.`
|
||||
: isTeammate()
|
||||
? `
|
||||
- The name, team_name, and mode parameters are not available in this context — teammates cannot spawn other teammates. Omit them to spawn a subagent.`
|
||||
: ''
|
||||
}${whenToForkSection}${writingThePromptSection}
|
||||
|
||||
${forkEnabled ? forkExamples : currentExamples}`
|
||||
}
|
||||
265
packages/builtin-tools/src/tools/AgentTool/resumeAgent.ts
Normal file
265
packages/builtin-tools/src/tools/AgentTool/resumeAgent.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import { promises as fsp } from 'fs'
|
||||
import { getSdkAgentProgressSummariesEnabled } from 'src/bootstrap/state.js'
|
||||
import { getSystemPrompt } from 'src/constants/prompts.js'
|
||||
import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'
|
||||
import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
|
||||
import type { ToolUseContext } from 'src/Tool.js'
|
||||
import { registerAsyncAgent } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'
|
||||
import { assembleToolPool } from 'src/tools.js'
|
||||
import { asAgentId } from 'src/types/ids.js'
|
||||
import { runWithAgentContext } from 'src/utils/agentContext.js'
|
||||
import { runWithCwdOverride } from 'src/utils/cwd.js'
|
||||
import { logForDebugging } from 'src/utils/debug.js'
|
||||
import {
|
||||
createUserMessage,
|
||||
filterOrphanedThinkingOnlyMessages,
|
||||
filterUnresolvedToolUses,
|
||||
filterWhitespaceOnlyAssistantMessages,
|
||||
} from 'src/utils/messages.js'
|
||||
import { getAgentModel } from 'src/utils/model/agent.js'
|
||||
import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'
|
||||
import {
|
||||
getAgentTranscript,
|
||||
readAgentMetadata,
|
||||
} from 'src/utils/sessionStorage.js'
|
||||
import { buildEffectiveSystemPrompt } from 'src/utils/systemPrompt.js'
|
||||
import type { SystemPrompt } from 'src/utils/systemPromptType.js'
|
||||
import { getTaskOutputPath } from 'src/utils/task/diskOutput.js'
|
||||
import { getParentSessionId } from 'src/utils/teammate.js'
|
||||
import { reconstructForSubagentResume } from 'src/utils/toolResultStorage.js'
|
||||
import { runAsyncAgentLifecycle } from './agentToolUtils.js'
|
||||
import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'
|
||||
import { FORK_AGENT, isForkSubagentEnabled } from './forkSubagent.js'
|
||||
import type { AgentDefinition } from './loadAgentsDir.js'
|
||||
import { isBuiltInAgent } from './loadAgentsDir.js'
|
||||
import { runAgent } from './runAgent.js'
|
||||
|
||||
export type ResumeAgentResult = {
|
||||
agentId: string
|
||||
description: string
|
||||
outputFile: string
|
||||
}
|
||||
export async function resumeAgentBackground({
|
||||
agentId,
|
||||
prompt,
|
||||
toolUseContext,
|
||||
canUseTool,
|
||||
invokingRequestId,
|
||||
}: {
|
||||
agentId: string
|
||||
prompt: string
|
||||
toolUseContext: ToolUseContext
|
||||
canUseTool: CanUseToolFn
|
||||
invokingRequestId?: string
|
||||
}): Promise<ResumeAgentResult> {
|
||||
const startTime = Date.now()
|
||||
const appState = toolUseContext.getAppState()
|
||||
// In-process teammates get a no-op setAppState; setAppStateForTasks
|
||||
// reaches the root store so task registration/progress/kill stay visible.
|
||||
const rootSetAppState =
|
||||
toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState
|
||||
const permissionMode = appState.toolPermissionContext.mode
|
||||
|
||||
const [transcript, meta] = await Promise.all([
|
||||
getAgentTranscript(asAgentId(agentId)),
|
||||
readAgentMetadata(asAgentId(agentId)),
|
||||
])
|
||||
if (!transcript) {
|
||||
throw new Error(`No transcript found for agent ID: ${agentId}`)
|
||||
}
|
||||
const resumedMessages = filterWhitespaceOnlyAssistantMessages(
|
||||
filterOrphanedThinkingOnlyMessages(
|
||||
filterUnresolvedToolUses(transcript.messages),
|
||||
),
|
||||
)
|
||||
const resumedReplacementState = reconstructForSubagentResume(
|
||||
toolUseContext.contentReplacementState,
|
||||
resumedMessages,
|
||||
transcript.contentReplacements,
|
||||
)
|
||||
// Best-effort: if the original worktree was removed externally, fall back
|
||||
// to parent cwd rather than crashing on chdir later.
|
||||
const resumedWorktreePath = meta?.worktreePath
|
||||
? await fsp.stat(meta.worktreePath).then(
|
||||
s => (s.isDirectory() ? meta.worktreePath : undefined),
|
||||
() => {
|
||||
logForDebugging(
|
||||
`Resumed worktree ${meta.worktreePath} no longer exists; falling back to parent cwd`,
|
||||
)
|
||||
return undefined
|
||||
},
|
||||
)
|
||||
: undefined
|
||||
if (resumedWorktreePath) {
|
||||
// Bump mtime so stale-worktree cleanup doesn't delete a just-resumed worktree (#22355)
|
||||
const now = new Date()
|
||||
await fsp.utimes(resumedWorktreePath, now, now)
|
||||
}
|
||||
|
||||
// Skip filterDeniedAgents re-gating — original spawn already passed permission checks
|
||||
let selectedAgent: AgentDefinition
|
||||
let isResumedFork = false
|
||||
if (meta?.agentType === FORK_AGENT.agentType) {
|
||||
selectedAgent = FORK_AGENT
|
||||
isResumedFork = true
|
||||
} else if (meta?.agentType) {
|
||||
const found = toolUseContext.options.agentDefinitions.activeAgents.find(
|
||||
a => a.agentType === meta.agentType,
|
||||
)
|
||||
selectedAgent = found ?? GENERAL_PURPOSE_AGENT
|
||||
} else {
|
||||
selectedAgent = GENERAL_PURPOSE_AGENT
|
||||
}
|
||||
|
||||
const uiDescription = meta?.description ?? '(resumed)'
|
||||
|
||||
let forkParentSystemPrompt: SystemPrompt | undefined
|
||||
if (isResumedFork) {
|
||||
if (toolUseContext.renderedSystemPrompt) {
|
||||
forkParentSystemPrompt = toolUseContext.renderedSystemPrompt
|
||||
} else {
|
||||
const mainThreadAgentDefinition = appState.agent
|
||||
? appState.agentDefinitions.activeAgents.find(
|
||||
a => a.agentType === appState.agent,
|
||||
)
|
||||
: undefined
|
||||
const additionalWorkingDirectories = Array.from(
|
||||
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
|
||||
)
|
||||
const defaultSystemPrompt = await getSystemPrompt(
|
||||
toolUseContext.options.tools,
|
||||
toolUseContext.options.mainLoopModel,
|
||||
additionalWorkingDirectories,
|
||||
toolUseContext.options.mcpClients,
|
||||
)
|
||||
forkParentSystemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition,
|
||||
toolUseContext,
|
||||
customSystemPrompt: toolUseContext.options.customSystemPrompt,
|
||||
defaultSystemPrompt,
|
||||
appendSystemPrompt: toolUseContext.options.appendSystemPrompt,
|
||||
})
|
||||
}
|
||||
if (!forkParentSystemPrompt) {
|
||||
throw new Error(
|
||||
'Cannot resume fork agent: unable to reconstruct parent system prompt',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve model for analytics metadata (runAgent resolves its own internally)
|
||||
const resolvedAgentModel = getAgentModel(
|
||||
selectedAgent.model,
|
||||
toolUseContext.options.mainLoopModel,
|
||||
undefined,
|
||||
permissionMode,
|
||||
)
|
||||
|
||||
const workerPermissionContext = {
|
||||
...appState.toolPermissionContext,
|
||||
mode: selectedAgent.permissionMode ?? 'acceptEdits',
|
||||
}
|
||||
const workerTools = isResumedFork
|
||||
? toolUseContext.options.tools
|
||||
: assembleToolPool(workerPermissionContext, appState.mcp.tools)
|
||||
|
||||
const runAgentParams: Parameters<typeof runAgent>[0] = {
|
||||
agentDefinition: selectedAgent,
|
||||
promptMessages: [
|
||||
...resumedMessages,
|
||||
createUserMessage({ content: prompt }),
|
||||
],
|
||||
toolUseContext,
|
||||
canUseTool,
|
||||
isAsync: true,
|
||||
querySource: getQuerySourceForAgent(
|
||||
selectedAgent.agentType,
|
||||
isBuiltInAgent(selectedAgent),
|
||||
),
|
||||
model: undefined,
|
||||
// Fork resume: pass parent's system prompt (cache-identical prefix).
|
||||
// Non-fork: undefined → runAgent recomputes under wrapWithCwd so
|
||||
// getCwd() sees resumedWorktreePath.
|
||||
override: isResumedFork
|
||||
? { systemPrompt: forkParentSystemPrompt }
|
||||
: undefined,
|
||||
availableTools: workerTools,
|
||||
// Transcript already contains the parent context slice from the
|
||||
// original fork. Re-supplying it would cause duplicate tool_use IDs.
|
||||
forkContextMessages: undefined,
|
||||
...(isResumedFork && { useExactTools: true }),
|
||||
// Re-persist so metadata survives runAgent's writeAgentMetadata overwrite
|
||||
worktreePath: resumedWorktreePath,
|
||||
description: meta?.description,
|
||||
contentReplacementState: resumedReplacementState,
|
||||
}
|
||||
|
||||
// Skip name-registry write — original entry persists from the initial spawn
|
||||
const agentBackgroundTask = registerAsyncAgent({
|
||||
agentId,
|
||||
description: uiDescription,
|
||||
prompt,
|
||||
selectedAgent,
|
||||
setAppState: rootSetAppState,
|
||||
toolUseId: toolUseContext.toolUseId,
|
||||
})
|
||||
|
||||
const metadata = {
|
||||
prompt,
|
||||
resolvedAgentModel,
|
||||
isBuiltInAgent: isBuiltInAgent(selectedAgent),
|
||||
startTime,
|
||||
agentType: selectedAgent.agentType,
|
||||
isAsync: true,
|
||||
}
|
||||
|
||||
const asyncAgentContext = {
|
||||
agentId,
|
||||
parentSessionId: getParentSessionId(),
|
||||
agentType: 'subagent' as const,
|
||||
subagentName: selectedAgent.agentType,
|
||||
isBuiltIn: isBuiltInAgent(selectedAgent),
|
||||
invokingRequestId,
|
||||
invocationKind: 'resume' as const,
|
||||
invocationEmitted: false,
|
||||
}
|
||||
|
||||
const wrapWithCwd = <T>(fn: () => T): T =>
|
||||
resumedWorktreePath ? runWithCwdOverride(resumedWorktreePath, fn) : fn()
|
||||
|
||||
void runWithAgentContext(asyncAgentContext, () =>
|
||||
wrapWithCwd(() =>
|
||||
runAsyncAgentLifecycle({
|
||||
taskId: agentBackgroundTask.agentId,
|
||||
abortController: agentBackgroundTask.abortController!,
|
||||
makeStream: onCacheSafeParams =>
|
||||
runAgent({
|
||||
...runAgentParams,
|
||||
override: {
|
||||
...runAgentParams.override,
|
||||
agentId: asAgentId(agentBackgroundTask.agentId),
|
||||
abortController: agentBackgroundTask.abortController!,
|
||||
},
|
||||
onCacheSafeParams,
|
||||
}),
|
||||
metadata,
|
||||
description: uiDescription,
|
||||
toolUseContext,
|
||||
rootSetAppState,
|
||||
agentIdForCleanup: agentId,
|
||||
enableSummarization:
|
||||
isCoordinatorMode() ||
|
||||
isForkSubagentEnabled() ||
|
||||
getSdkAgentProgressSummariesEnabled(),
|
||||
getWorktreeResult: async () =>
|
||||
resumedWorktreePath ? { worktreePath: resumedWorktreePath } : {},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
return {
|
||||
agentId,
|
||||
description: uiDescription,
|
||||
outputFile: getTaskOutputPath(agentId),
|
||||
}
|
||||
}
|
||||
1000
packages/builtin-tools/src/tools/AgentTool/runAgent.ts
Normal file
1000
packages/builtin-tools/src/tools/AgentTool/runAgent.ts
Normal file
File diff suppressed because it is too large
Load Diff
4
packages/builtin-tools/src/tools/AgentTool/src/Tool.ts
Normal file
4
packages/builtin-tools/src/tools/AgentTool/src/Tool.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type buildTool = any;
|
||||
export type ToolDef = any;
|
||||
export type toolMatchesName = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ConfigurableShortcutHint = any;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CtrlOToExpand = any;
|
||||
export type SubAgentProvider = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Byline = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type KeyboardShortcutHint = any;
|
||||
@@ -0,0 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type Message = any;
|
||||
export type NormalizedUserMessage = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logForDebugging = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getQuerySourceForAgent = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type SettingSource = any;
|
||||
@@ -0,0 +1,342 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
getAllowedChannels,
|
||||
getQuestionPreviewFormat,
|
||||
} from 'src/bootstrap/state.js'
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js'
|
||||
import { BLACK_CIRCLE } from 'src/constants/figures.js'
|
||||
import { getModeColor } from 'src/utils/permissions/PermissionMode.js'
|
||||
import { z } from 'zod/v4'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Tool } from 'src/Tool.js'
|
||||
import { buildTool, type ToolDef } from 'src/Tool.js'
|
||||
import { lazySchema } from 'src/utils/lazySchema.js'
|
||||
import {
|
||||
ASK_USER_QUESTION_TOOL_CHIP_WIDTH,
|
||||
ASK_USER_QUESTION_TOOL_NAME,
|
||||
ASK_USER_QUESTION_TOOL_PROMPT,
|
||||
DESCRIPTION,
|
||||
PREVIEW_FEATURE_PROMPT,
|
||||
} from './prompt.js'
|
||||
|
||||
const questionOptionSchema = lazySchema(() =>
|
||||
z.object({
|
||||
label: z
|
||||
.string()
|
||||
.describe(
|
||||
'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.',
|
||||
),
|
||||
description: z
|
||||
.string()
|
||||
.describe(
|
||||
'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.',
|
||||
),
|
||||
preview: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const questionSchema = lazySchema(() =>
|
||||
z.object({
|
||||
question: z
|
||||
.string()
|
||||
.describe(
|
||||
'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"',
|
||||
),
|
||||
header: z
|
||||
.string()
|
||||
.describe(
|
||||
`Very short label displayed as a chip/tag (max ${ASK_USER_QUESTION_TOOL_CHIP_WIDTH} chars). Examples: "Auth method", "Library", "Approach".`,
|
||||
),
|
||||
options: z
|
||||
.array(questionOptionSchema())
|
||||
.min(2)
|
||||
.max(4)
|
||||
.describe(
|
||||
`The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.`,
|
||||
),
|
||||
multiSelect: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe(
|
||||
'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.',
|
||||
),
|
||||
}),
|
||||
)
|
||||
|
||||
const annotationsSchema = lazySchema(() => {
|
||||
const annotationSchema = z.object({
|
||||
preview: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'The preview content of the selected option, if the question used previews.',
|
||||
),
|
||||
notes: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe('Free-text notes the user added to their selection.'),
|
||||
})
|
||||
|
||||
return z
|
||||
.record(z.string(), annotationSchema)
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.',
|
||||
)
|
||||
})
|
||||
|
||||
const UNIQUENESS_REFINE = {
|
||||
check: (data: {
|
||||
questions: { question: string; options: { label: string }[] }[]
|
||||
}) => {
|
||||
const questions = data.questions.map(q => q.question)
|
||||
if (questions.length !== new Set(questions).size) {
|
||||
return false
|
||||
}
|
||||
for (const question of data.questions) {
|
||||
const labels = question.options.map(opt => opt.label)
|
||||
if (labels.length !== new Set(labels).size) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
message:
|
||||
'Question texts must be unique, option labels must be unique within each question',
|
||||
} as const
|
||||
|
||||
const commonFields = lazySchema(() => ({
|
||||
answers: z
|
||||
.record(z.string(), z.string())
|
||||
.optional()
|
||||
.describe('User answers collected by the permission component'),
|
||||
annotations: annotationsSchema(),
|
||||
metadata: z
|
||||
.object({
|
||||
source: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.',
|
||||
),
|
||||
})
|
||||
.optional()
|
||||
.describe(
|
||||
'Optional metadata for tracking and analytics purposes. Not displayed to user.',
|
||||
),
|
||||
}))
|
||||
|
||||
const inputSchema = lazySchema(() =>
|
||||
z
|
||||
.strictObject({
|
||||
questions: z
|
||||
.array(questionSchema())
|
||||
.min(1)
|
||||
.max(4)
|
||||
.describe('Questions to ask the user (1-4 questions)'),
|
||||
...commonFields(),
|
||||
})
|
||||
.refine(UNIQUENESS_REFINE.check, {
|
||||
message: UNIQUENESS_REFINE.message,
|
||||
}),
|
||||
)
|
||||
type InputSchema = ReturnType<typeof inputSchema>
|
||||
|
||||
const outputSchema = lazySchema(() =>
|
||||
z.object({
|
||||
questions: z
|
||||
.array(questionSchema())
|
||||
.describe('The questions that were asked'),
|
||||
answers: z
|
||||
.record(z.string(), z.string())
|
||||
.describe(
|
||||
'The answers provided by the user (question text -> answer string; multi-select answers are comma-separated)',
|
||||
),
|
||||
annotations: annotationsSchema(),
|
||||
}),
|
||||
)
|
||||
type OutputSchema = ReturnType<typeof outputSchema>
|
||||
|
||||
// SDK schemas are identical to internal schemas now that `preview` and
|
||||
// `annotations` are public (configurable via `toolConfig.askUserQuestion`).
|
||||
export const _sdkInputSchema = inputSchema
|
||||
export const _sdkOutputSchema = outputSchema
|
||||
|
||||
export type Question = z.infer<ReturnType<typeof questionSchema>>
|
||||
export type QuestionOption = z.infer<ReturnType<typeof questionOptionSchema>>
|
||||
export type Output = z.infer<OutputSchema>
|
||||
|
||||
function AskUserQuestionResultMessage({
|
||||
answers,
|
||||
}: {
|
||||
answers: Output['answers']
|
||||
}): React.ReactNode {
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box flexDirection="row">
|
||||
<Text color={getModeColor('default')}>{BLACK_CIRCLE} </Text>
|
||||
<Text>User answered Claude's questions:</Text>
|
||||
</Box>
|
||||
<MessageResponse>
|
||||
<Box flexDirection="column">
|
||||
{Object.entries(answers).map(([questionText, answer]) => (
|
||||
<Text key={questionText} color="inactive">
|
||||
· {questionText} → {answer}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
</MessageResponse>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
||||
name: ASK_USER_QUESTION_TOOL_NAME,
|
||||
searchHint: 'prompt the user with a multiple-choice question',
|
||||
maxResultSizeChars: 100_000,
|
||||
shouldDefer: true,
|
||||
async description() {
|
||||
return DESCRIPTION
|
||||
},
|
||||
async prompt() {
|
||||
const format = getQuestionPreviewFormat()
|
||||
if (format === undefined) {
|
||||
// SDK consumer that hasn't opted into a preview format — omit preview
|
||||
// guidance (they may not render the field at all).
|
||||
return ASK_USER_QUESTION_TOOL_PROMPT
|
||||
}
|
||||
return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]
|
||||
},
|
||||
get inputSchema(): InputSchema {
|
||||
return inputSchema()
|
||||
},
|
||||
get outputSchema(): OutputSchema {
|
||||
return outputSchema()
|
||||
},
|
||||
userFacingName() {
|
||||
return ''
|
||||
},
|
||||
isEnabled() {
|
||||
// When --channels is active the user is likely on Telegram/Discord, not
|
||||
// watching the TUI. The multiple-choice dialog would hang with nobody at
|
||||
// the keyboard. Channel permission relay already skips
|
||||
// requiresUserInteraction() tools (interactiveHandler.ts) so there's
|
||||
// no alternate approval path.
|
||||
if (
|
||||
(feature('KAIROS') || feature('KAIROS_CHANNELS')) &&
|
||||
getAllowedChannels().length > 0
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
isConcurrencySafe() {
|
||||
return true
|
||||
},
|
||||
isReadOnly() {
|
||||
return true
|
||||
},
|
||||
toAutoClassifierInput(input) {
|
||||
return input.questions.map(q => q.question).join(' | ')
|
||||
},
|
||||
requiresUserInteraction() {
|
||||
return true
|
||||
},
|
||||
async validateInput({ questions }) {
|
||||
if (getQuestionPreviewFormat() !== 'html') {
|
||||
return { result: true }
|
||||
}
|
||||
for (const q of questions) {
|
||||
for (const opt of q.options) {
|
||||
const err = validateHtmlPreview(opt.preview)
|
||||
if (err) {
|
||||
return {
|
||||
result: false,
|
||||
message: `Option "${opt.label}" in question "${q.question}": ${err}`,
|
||||
errorCode: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return { result: true }
|
||||
},
|
||||
async checkPermissions(input) {
|
||||
return {
|
||||
behavior: 'ask' as const,
|
||||
message: 'Answer questions?',
|
||||
updatedInput: input,
|
||||
}
|
||||
},
|
||||
renderToolUseMessage() {
|
||||
return null
|
||||
},
|
||||
renderToolUseProgressMessage() {
|
||||
return null
|
||||
},
|
||||
renderToolResultMessage({ answers }, _toolUseID) {
|
||||
return <AskUserQuestionResultMessage answers={answers} />
|
||||
},
|
||||
renderToolUseRejectedMessage() {
|
||||
return (
|
||||
<Box flexDirection="row" marginTop={1}>
|
||||
<Text color={getModeColor('default')}>{BLACK_CIRCLE} </Text>
|
||||
<Text>User declined to answer questions</Text>
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
renderToolUseErrorMessage() {
|
||||
return null
|
||||
},
|
||||
async call({ questions, answers = {}, annotations }, _context) {
|
||||
return {
|
||||
data: { questions, answers, ...(annotations && { annotations }) },
|
||||
}
|
||||
},
|
||||
mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) {
|
||||
const answersText = Object.entries(answers)
|
||||
.map(([questionText, answer]) => {
|
||||
const annotation = annotations?.[questionText]
|
||||
const parts = [`"${questionText}"="${answer}"`]
|
||||
if (annotation?.preview) {
|
||||
parts.push(`selected preview:\n${annotation.preview}`)
|
||||
}
|
||||
if (annotation?.notes) {
|
||||
parts.push(`user notes: ${annotation.notes}`)
|
||||
}
|
||||
return parts.join(' ')
|
||||
})
|
||||
.join(', ')
|
||||
|
||||
return {
|
||||
type: 'tool_result',
|
||||
content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`,
|
||||
tool_use_id: toolUseID,
|
||||
}
|
||||
},
|
||||
} satisfies ToolDef<InputSchema, Output>)
|
||||
|
||||
// Lightweight HTML fragment check. Not a parser — HTML5 parsers are
|
||||
// error-recovering by spec and accept anything. We're checking model intent
|
||||
// (did it emit HTML?) and catching the specific things we told it not to do.
|
||||
function validateHtmlPreview(preview: string | undefined): string | null {
|
||||
if (preview === undefined) return null
|
||||
if (/<\s*(html|body|!doctype)\b/i.test(preview)) {
|
||||
return 'preview must be an HTML fragment, not a full document (no <html>, <body>, or <!DOCTYPE>)'
|
||||
}
|
||||
// SDK consumers typically set this via innerHTML — disallow executable/style
|
||||
// tags so a preview can't run code or restyle the host page. Inline event
|
||||
// handlers (onclick etc.) are still possible; consumers should sanitize.
|
||||
if (/<\s*(script|style)\b/i.test(preview)) {
|
||||
return 'preview must not contain <script> or <style> tags. Use inline styles via the style attribute if needed.'
|
||||
}
|
||||
if (!/<[a-z][^>]*>/i.test(preview)) {
|
||||
return 'preview must contain HTML (previewFormat is set to "html"). Wrap content in a tag like <div> or <pre>.'
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { EXIT_PLAN_MODE_TOOL_NAME } from '../ExitPlanModeTool/constants.js'
|
||||
|
||||
export const ASK_USER_QUESTION_TOOL_NAME = 'AskUserQuestion'
|
||||
|
||||
export const ASK_USER_QUESTION_TOOL_CHIP_WIDTH = 12
|
||||
|
||||
export const DESCRIPTION =
|
||||
'Asks the user multiple choice questions to gather information, clarify ambiguity, understand preferences, make decisions or offer them choices.'
|
||||
|
||||
export const PREVIEW_FEATURE_PROMPT = {
|
||||
markdown: `
|
||||
Preview feature:
|
||||
Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
|
||||
- ASCII mockups of UI layouts or components
|
||||
- Code snippets showing different implementations
|
||||
- Diagram variations
|
||||
- Configuration examples
|
||||
|
||||
Preview content is rendered as markdown in a monospace box. Multi-line text with newlines is supported. When any option has a preview, the UI switches to a side-by-side layout with a vertical option list on the left and preview on the right. Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).
|
||||
`,
|
||||
html: `
|
||||
Preview feature:
|
||||
Use the optional \`preview\` field on options when presenting concrete artifacts that users need to visually compare:
|
||||
- HTML mockups of UI layouts or components
|
||||
- Formatted code snippets showing different implementations
|
||||
- Visual comparisons or diagrams
|
||||
|
||||
Preview content must be a self-contained HTML fragment (no <html>/<body> wrapper, no <script> or <style> tags — use inline style attributes instead). Do not use previews for simple preference questions where labels and descriptions suffice. Note: previews are only supported for single-select questions (not multiSelect).
|
||||
`,
|
||||
} as const
|
||||
|
||||
export const ASK_USER_QUESTION_TOOL_PROMPT = `Use this tool when you need to ask the user questions during execution. This allows you to:
|
||||
1. Gather user preferences or requirements
|
||||
2. Clarify ambiguous instructions
|
||||
3. Get decisions on implementation choices as you work
|
||||
4. Offer choices to the user about what direction to take.
|
||||
|
||||
Usage notes:
|
||||
- Users will always be able to select "Other" to provide custom text input
|
||||
- Use multiSelect: true to allow multiple answers to be selected for a question
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
||||
|
||||
Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" - use ${EXIT_PLAN_MODE_TOOL_NAME} for plan approval. IMPORTANT: Do not reference "the plan" in your questions (e.g., "Do you have feedback about the plan?", "Does the plan look good?") because the user cannot see the plan in the UI until you call ${EXIT_PLAN_MODE_TOOL_NAME}. If you need plan approval, use ${EXIT_PLAN_MODE_TOOL_NAME} instead.
|
||||
`
|
||||
@@ -0,0 +1,3 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getAllowedChannels = any;
|
||||
export type getQuestionPreviewFormat = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type MessageResponse = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type BLACK_CIRCLE = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getModeColor = any;
|
||||
1472
packages/builtin-tools/src/tools/BashTool/BashTool.tsx
Normal file
1472
packages/builtin-tools/src/tools/BashTool/BashTool.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,129 @@
|
||||
import React from 'react'
|
||||
import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js'
|
||||
import { OutputLine } from 'src/components/shell/OutputLine.js'
|
||||
import { ShellTimeDisplay } from 'src/components/shell/ShellTimeDisplay.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import type { Out as BashOut } from './BashTool.js'
|
||||
|
||||
type Props = {
|
||||
content: Omit<BashOut, 'interrupted'>
|
||||
verbose: boolean
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
// Pattern to match "Shell cwd was reset to <path>" message
|
||||
// Use (?:^|\n) to match either start of string or after a newline
|
||||
const SHELL_CWD_RESET_PATTERN = /(?:^|\n)(Shell cwd was reset to .+)$/
|
||||
|
||||
/**
|
||||
* Extracts sandbox violations from stderr if present
|
||||
* Returns both the cleaned stderr and the violations content
|
||||
*/
|
||||
function extractSandboxViolations(stderr: string): {
|
||||
cleanedStderr: string
|
||||
} {
|
||||
const violationsMatch = stderr.match(
|
||||
/<sandbox_violations>([\s\S]*?)<\/sandbox_violations>/,
|
||||
)
|
||||
|
||||
if (!violationsMatch) {
|
||||
return { cleanedStderr: stderr }
|
||||
}
|
||||
|
||||
// Remove the sandbox violations section from stderr
|
||||
const cleanedStderr = removeSandboxViolationTags(stderr).trim()
|
||||
|
||||
return {
|
||||
cleanedStderr,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the "Shell cwd was reset" warning message from stderr
|
||||
* Returns the cleaned stderr and the warning message separately
|
||||
*/
|
||||
function extractCwdResetWarning(stderr: string): {
|
||||
cleanedStderr: string
|
||||
cwdResetWarning: string | null
|
||||
} {
|
||||
const match = stderr.match(SHELL_CWD_RESET_PATTERN)
|
||||
if (!match) {
|
||||
return { cleanedStderr: stderr, cwdResetWarning: null }
|
||||
}
|
||||
|
||||
// Extract the warning message from capture group 1
|
||||
const cwdResetWarning = match[1] ?? null
|
||||
// Remove the warning from stderr (replace the full match)
|
||||
const cleanedStderr = stderr.replace(SHELL_CWD_RESET_PATTERN, '').trim()
|
||||
|
||||
return { cleanedStderr, cwdResetWarning }
|
||||
}
|
||||
|
||||
export default function BashToolResultMessage({
|
||||
content: {
|
||||
stdout = '',
|
||||
stderr: stdErrWithViolations = '',
|
||||
isImage,
|
||||
returnCodeInterpretation,
|
||||
noOutputExpected,
|
||||
backgroundTaskId,
|
||||
},
|
||||
verbose,
|
||||
timeoutMs,
|
||||
}: Props): React.ReactNode {
|
||||
// Extract sandbox violations from stderr as it feels cleaner on the UI
|
||||
// We want the model to see the violations, so it can explain what went wrong, and the
|
||||
// user can access them in the violation logs
|
||||
const { cleanedStderr: stderrWithoutViolations } =
|
||||
extractSandboxViolations(stdErrWithViolations)
|
||||
|
||||
// Extract "Shell cwd was reset" warning to render it with warning color instead of error
|
||||
const { cleanedStderr: stderr, cwdResetWarning } = extractCwdResetWarning(
|
||||
stderrWithoutViolations,
|
||||
)
|
||||
|
||||
// If this is an image, we don't want to truncate it in the UI
|
||||
if (isImage) {
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>[Image data detected and sent to Claude]</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{stdout !== '' ? <OutputLine content={stdout} verbose={verbose} /> : null}
|
||||
{stderr.trim() !== '' ? (
|
||||
<OutputLine content={stderr} verbose={verbose} isError />
|
||||
) : null}
|
||||
{cwdResetWarning ? (
|
||||
<MessageResponse>
|
||||
<Text dimColor>{cwdResetWarning}</Text>
|
||||
</MessageResponse>
|
||||
) : null}
|
||||
{stdout === '' && stderr.trim() === '' && !cwdResetWarning ? (
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>
|
||||
{backgroundTaskId ? (
|
||||
<>
|
||||
Running in the background{' '}
|
||||
<KeyboardShortcutHint shortcut="↓" action="manage" parens />
|
||||
</>
|
||||
) : (
|
||||
returnCodeInterpretation ||
|
||||
(noOutputExpected ? 'Done' : '(No output)')
|
||||
)}
|
||||
</Text>
|
||||
</MessageResponse>
|
||||
) : null}
|
||||
{timeoutMs && (
|
||||
<MessageResponse>
|
||||
<ShellTimeDisplay timeoutMs={timeoutMs} />
|
||||
</MessageResponse>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
213
packages/builtin-tools/src/tools/BashTool/UI.tsx
Normal file
213
packages/builtin-tools/src/tools/BashTool/UI.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'
|
||||
import * as React from 'react'
|
||||
import { KeyboardShortcutHint } from '@anthropic/ink'
|
||||
import { FallbackToolUseErrorMessage } from 'src/components/FallbackToolUseErrorMessage.js'
|
||||
import { MessageResponse } from 'src/components/MessageResponse.js'
|
||||
import { ShellProgressMessage } from 'src/components/shell/ShellProgressMessage.js'
|
||||
import { Box, Text } from '@anthropic/ink'
|
||||
import { useKeybinding } from 'src/keybindings/useKeybinding.js'
|
||||
import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js'
|
||||
import { useAppStateStore, useSetAppState } from 'src/state/AppState.js'
|
||||
import type { Tool } from 'src/Tool.js'
|
||||
import { backgroundAll } from 'src/tasks/LocalShellTask/LocalShellTask.js'
|
||||
import type { ProgressMessage } from 'src/types/message.js'
|
||||
import { env } from 'src/utils/env.js'
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { getDisplayPath } from 'src/utils/file.js'
|
||||
import { isFullscreenEnvEnabled } from 'src/utils/fullscreen.js'
|
||||
import type { ThemeName } from 'src/utils/theme.js'
|
||||
import type { BashProgress, BashToolInput, Out } from './BashTool.js'
|
||||
import BashToolResultMessage from './BashToolResultMessage.js'
|
||||
import { extractBashCommentLabel } from './commentLabel.js'
|
||||
import { parseSedEditCommand } from './sedEditParser.js'
|
||||
|
||||
// Constants for command display
|
||||
const MAX_COMMAND_DISPLAY_LINES = 2
|
||||
const MAX_COMMAND_DISPLAY_CHARS = 160
|
||||
|
||||
// Simple component to show background hint and handle ctrl+b
|
||||
// When ctrl+b is pressed, backgrounds ALL running foreground commands
|
||||
export function BackgroundHint({
|
||||
onBackground,
|
||||
}: {
|
||||
onBackground?: () => void
|
||||
} = {}): React.ReactElement | null {
|
||||
const store = useAppStateStore()
|
||||
const setAppState = useSetAppState()
|
||||
|
||||
// Handler for task:background - background all foreground tasks
|
||||
const handleBackground = React.useCallback(() => {
|
||||
// Background ALL foreground bash tasks
|
||||
backgroundAll(() => store.getState(), setAppState)
|
||||
// Also call the optional callback (used for non-bash tasks like agents)
|
||||
onBackground?.()
|
||||
}, [store, setAppState, onBackground])
|
||||
|
||||
useKeybinding('task:background', handleBackground, {
|
||||
context: 'Task',
|
||||
})
|
||||
|
||||
// Get the configured shortcut for task:background
|
||||
const baseShortcut = useShortcutDisplay('task:background', 'Task', 'ctrl+b')
|
||||
// In tmux, ctrl+b is the prefix key, so users need to press it twice to send ctrl+b
|
||||
const shortcut =
|
||||
env.terminal === 'tmux' && baseShortcut === 'ctrl+b'
|
||||
? 'ctrl+b ctrl+b (twice)'
|
||||
: baseShortcut
|
||||
|
||||
// Don't show background hint if background tasks are disabled
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Box paddingLeft={5}>
|
||||
<Text dimColor>
|
||||
<KeyboardShortcutHint
|
||||
shortcut={shortcut}
|
||||
action="run in background"
|
||||
parens
|
||||
/>
|
||||
</Text>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderToolUseMessage(
|
||||
input: Partial<BashToolInput>,
|
||||
{ verbose, theme: _theme }: { verbose: boolean; theme: ThemeName },
|
||||
): React.ReactNode {
|
||||
const { command } = input
|
||||
if (!command) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Render sed in-place edits like file edits (show file path only)
|
||||
const sedInfo = parseSedEditCommand(command)
|
||||
if (sedInfo) {
|
||||
return verbose ? sedInfo.filePath : getDisplayPath(sedInfo.filePath)
|
||||
}
|
||||
|
||||
if (!verbose) {
|
||||
const lines = command.split('\n')
|
||||
|
||||
if (isFullscreenEnvEnabled()) {
|
||||
const label = extractBashCommentLabel(command)
|
||||
if (label) {
|
||||
return label.length > MAX_COMMAND_DISPLAY_CHARS
|
||||
? label.slice(0, MAX_COMMAND_DISPLAY_CHARS) + '…'
|
||||
: label
|
||||
}
|
||||
}
|
||||
|
||||
const needsLineTruncation = lines.length > MAX_COMMAND_DISPLAY_LINES
|
||||
const needsCharTruncation = command.length > MAX_COMMAND_DISPLAY_CHARS
|
||||
|
||||
if (needsLineTruncation || needsCharTruncation) {
|
||||
let truncated = command
|
||||
|
||||
// First truncate by lines if needed
|
||||
if (needsLineTruncation) {
|
||||
truncated = lines.slice(0, MAX_COMMAND_DISPLAY_LINES).join('\n')
|
||||
}
|
||||
|
||||
// Then truncate by chars if still too long
|
||||
if (truncated.length > MAX_COMMAND_DISPLAY_CHARS) {
|
||||
truncated = truncated.slice(0, MAX_COMMAND_DISPLAY_CHARS)
|
||||
}
|
||||
|
||||
return <Text>{truncated.trim()}…</Text>
|
||||
}
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
export function renderToolUseProgressMessage(
|
||||
progressMessagesForMessage: ProgressMessage<BashProgress>[],
|
||||
{
|
||||
verbose,
|
||||
tools: _tools,
|
||||
terminalSize: _terminalSize,
|
||||
inProgressToolCallCount: _inProgressToolCallCount,
|
||||
}: {
|
||||
tools: Tool[]
|
||||
verbose: boolean
|
||||
terminalSize?: { columns: number; rows: number }
|
||||
inProgressToolCallCount?: number
|
||||
},
|
||||
): React.ReactNode {
|
||||
const lastProgress = progressMessagesForMessage.at(-1)
|
||||
|
||||
if (!lastProgress || !lastProgress.data) {
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>Running…</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
|
||||
const data = lastProgress.data
|
||||
|
||||
return (
|
||||
<ShellProgressMessage
|
||||
fullOutput={data.fullOutput}
|
||||
output={data.output}
|
||||
elapsedTimeSeconds={data.elapsedTimeSeconds}
|
||||
totalLines={data.totalLines}
|
||||
totalBytes={data.totalBytes}
|
||||
timeoutMs={data.timeoutMs}
|
||||
taskId={data.taskId}
|
||||
verbose={verbose}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderToolUseQueuedMessage(): React.ReactNode {
|
||||
return (
|
||||
<MessageResponse height={1}>
|
||||
<Text dimColor>Waiting…</Text>
|
||||
</MessageResponse>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderToolResultMessage(
|
||||
content: Out,
|
||||
progressMessagesForMessage: ProgressMessage<BashProgress>[],
|
||||
{
|
||||
verbose,
|
||||
theme: _theme,
|
||||
tools: _tools,
|
||||
style: _style,
|
||||
}: {
|
||||
verbose: boolean
|
||||
theme: ThemeName
|
||||
tools: Tool[]
|
||||
style?: 'condensed'
|
||||
},
|
||||
): React.ReactNode {
|
||||
const lastProgress = progressMessagesForMessage.at(-1)
|
||||
const timeoutMs = lastProgress?.data?.timeoutMs
|
||||
return (
|
||||
<BashToolResultMessage
|
||||
content={content}
|
||||
verbose={verbose}
|
||||
timeoutMs={timeoutMs}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function renderToolUseErrorMessage(
|
||||
result: ToolResultBlockParam['content'],
|
||||
{
|
||||
verbose,
|
||||
progressMessagesForMessage: _progressMessagesForMessage,
|
||||
tools: _tools,
|
||||
}: {
|
||||
verbose: boolean
|
||||
progressMessagesForMessage: ProgressMessage<BashProgress>[]
|
||||
tools: Tool[]
|
||||
},
|
||||
): React.ReactNode {
|
||||
return <FallbackToolUseErrorMessage result={result} verbose={verbose} />
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { mock, describe, expect, test } from "bun:test";
|
||||
|
||||
// Mock commands.ts to cut the heavy shell/prefix.ts → analytics → api chain
|
||||
mock.module("src/utils/bash/commands.ts", () => ({
|
||||
splitCommand_DEPRECATED: (cmd: string) =>
|
||||
cmd.split(/\s*(?:[|;&]+)\s*/).filter(Boolean),
|
||||
quote: (args: string[]) => args.join(" "),
|
||||
}));
|
||||
|
||||
const { interpretCommandResult } = await import("../commandSemantics");
|
||||
|
||||
describe("interpretCommandResult", () => {
|
||||
// ─── Default semantics ────────────────────────────────────────────
|
||||
test("exit 0 is not an error for unknown commands", () => {
|
||||
const result = interpretCommandResult("echo hello", 0, "hello", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("non-zero exit is an error for unknown commands", () => {
|
||||
const result = interpretCommandResult("echo hello", 1, "", "fail");
|
||||
expect(result.isError).toBe(true);
|
||||
expect(result.message).toContain("exit code 1");
|
||||
});
|
||||
|
||||
// ─── grep semantics ──────────────────────────────────────────────
|
||||
test("grep exit 0 is not an error", () => {
|
||||
const result = interpretCommandResult("grep pattern file", 0, "match", "");
|
||||
expect(result.isError).toBe(false);
|
||||
});
|
||||
|
||||
test("grep exit 1 means no matches (not error)", () => {
|
||||
const result = interpretCommandResult("grep pattern file", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("No matches found");
|
||||
});
|
||||
|
||||
test("grep exit 2 is an error", () => {
|
||||
const result = interpretCommandResult("grep pattern file", 2, "", "err");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
// ─── diff semantics ──────────────────────────────────────────────
|
||||
test("diff exit 1 means files differ (not error)", () => {
|
||||
const result = interpretCommandResult("diff a.txt b.txt", 1, "diff", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("Files differ");
|
||||
});
|
||||
|
||||
test("diff exit 2 is an error", () => {
|
||||
const result = interpretCommandResult("diff a.txt b.txt", 2, "", "err");
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
|
||||
// ─── test/[ semantics ────────────────────────────────────────────
|
||||
test("test exit 1 means condition false (not error)", () => {
|
||||
const result = interpretCommandResult("test -f nofile", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("Condition is false");
|
||||
});
|
||||
|
||||
// ─── piped commands ──────────────────────────────────────────────
|
||||
test("uses last command in pipe for semantics", () => {
|
||||
// "cat file | grep pattern" → last command is "grep pattern"
|
||||
const result = interpretCommandResult(
|
||||
"cat file | grep pattern",
|
||||
1,
|
||||
"",
|
||||
""
|
||||
);
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("No matches found");
|
||||
});
|
||||
|
||||
// ─── rg (ripgrep) semantics ──────────────────────────────────────
|
||||
test("rg exit 1 means no matches (not error)", () => {
|
||||
const result = interpretCommandResult("rg pattern", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("No matches found");
|
||||
});
|
||||
|
||||
// ─── find semantics ──────────────────────────────────────────────
|
||||
test("find exit 1 is partial success", () => {
|
||||
const result = interpretCommandResult("find . -name '*.ts'", 1, "", "");
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.message).toBe("Some directories were inaccessible");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getDestructiveCommandWarning } from "../destructiveCommandWarning";
|
||||
|
||||
describe("getDestructiveCommandWarning", () => {
|
||||
// ─── Git data loss ─────────────────────────────────────────────────
|
||||
test("detects git reset --hard", () => {
|
||||
const w = getDestructiveCommandWarning("git reset --hard HEAD~1");
|
||||
expect(w).toContain("discard uncommitted changes");
|
||||
});
|
||||
|
||||
test("detects git push --force", () => {
|
||||
const w = getDestructiveCommandWarning("git push --force origin main");
|
||||
expect(w).toContain("overwrite remote history");
|
||||
});
|
||||
|
||||
test("detects git push -f", () => {
|
||||
expect(getDestructiveCommandWarning("git push -f")).toContain(
|
||||
"overwrite remote history"
|
||||
);
|
||||
});
|
||||
|
||||
test("detects git clean -f", () => {
|
||||
const w = getDestructiveCommandWarning("git clean -fd");
|
||||
expect(w).toContain("delete untracked files");
|
||||
});
|
||||
|
||||
test("does not flag git clean --dry-run", () => {
|
||||
expect(getDestructiveCommandWarning("git clean -fdn")).toBeNull();
|
||||
});
|
||||
|
||||
test("detects git checkout .", () => {
|
||||
const w = getDestructiveCommandWarning("git checkout -- .");
|
||||
expect(w).toContain("discard all working tree changes");
|
||||
});
|
||||
|
||||
test("detects git restore .", () => {
|
||||
const w = getDestructiveCommandWarning("git restore -- .");
|
||||
expect(w).toContain("discard all working tree changes");
|
||||
});
|
||||
|
||||
test("detects git stash drop", () => {
|
||||
const w = getDestructiveCommandWarning("git stash drop");
|
||||
expect(w).toContain("remove stashed changes");
|
||||
});
|
||||
|
||||
test("detects git branch -D", () => {
|
||||
const w = getDestructiveCommandWarning("git branch -D feature");
|
||||
expect(w).toContain("force-delete a branch");
|
||||
});
|
||||
|
||||
// ─── Git safety bypass ────────────────────────────────────────────
|
||||
test("detects --no-verify", () => {
|
||||
const w = getDestructiveCommandWarning("git commit --no-verify -m 'x'");
|
||||
expect(w).toContain("skip safety hooks");
|
||||
});
|
||||
|
||||
test("detects git commit --amend", () => {
|
||||
const w = getDestructiveCommandWarning("git commit --amend");
|
||||
expect(w).toContain("rewrite the last commit");
|
||||
});
|
||||
|
||||
// ─── File deletion ────────────────────────────────────────────────
|
||||
test("detects rm -rf", () => {
|
||||
const w = getDestructiveCommandWarning("rm -rf /tmp/dir");
|
||||
expect(w).toContain("recursively force-remove");
|
||||
});
|
||||
|
||||
test("detects rm -r", () => {
|
||||
const w = getDestructiveCommandWarning("rm -r dir");
|
||||
expect(w).toContain("recursively remove");
|
||||
});
|
||||
|
||||
test("detects rm -f", () => {
|
||||
const w = getDestructiveCommandWarning("rm -f file.txt");
|
||||
expect(w).toContain("force-remove");
|
||||
});
|
||||
|
||||
// ─── Database ─────────────────────────────────────────────────────
|
||||
test("detects DROP TABLE", () => {
|
||||
const w = getDestructiveCommandWarning("psql -c 'DROP TABLE users'");
|
||||
expect(w).toContain("drop or truncate");
|
||||
});
|
||||
|
||||
test("detects TRUNCATE TABLE", () => {
|
||||
const w = getDestructiveCommandWarning("TRUNCATE TABLE logs");
|
||||
expect(w).toContain("drop or truncate");
|
||||
});
|
||||
|
||||
test("detects DELETE FROM without WHERE", () => {
|
||||
const w = getDestructiveCommandWarning("DELETE FROM users;");
|
||||
expect(w).toContain("delete all rows");
|
||||
});
|
||||
|
||||
// ─── Infrastructure ───────────────────────────────────────────────
|
||||
test("detects kubectl delete", () => {
|
||||
const w = getDestructiveCommandWarning("kubectl delete pod my-pod");
|
||||
expect(w).toContain("delete Kubernetes");
|
||||
});
|
||||
|
||||
test("detects terraform destroy", () => {
|
||||
const w = getDestructiveCommandWarning("terraform destroy");
|
||||
expect(w).toContain("destroy Terraform");
|
||||
});
|
||||
|
||||
// ─── Safe commands ────────────────────────────────────────────────
|
||||
test("returns null for safe commands", () => {
|
||||
expect(getDestructiveCommandWarning("ls -la")).toBeNull();
|
||||
expect(getDestructiveCommandWarning("git status")).toBeNull();
|
||||
expect(getDestructiveCommandWarning("npm install")).toBeNull();
|
||||
expect(getDestructiveCommandWarning("cat file.txt")).toBeNull();
|
||||
});
|
||||
});
|
||||
265
packages/builtin-tools/src/tools/BashTool/bashCommandHelpers.ts
Normal file
265
packages/builtin-tools/src/tools/BashTool/bashCommandHelpers.ts
Normal file
@@ -0,0 +1,265 @@
|
||||
import type { z } from 'zod/v4'
|
||||
import {
|
||||
isUnsafeCompoundCommand_DEPRECATED,
|
||||
splitCommand_DEPRECATED,
|
||||
} from 'src/utils/bash/commands.js'
|
||||
import {
|
||||
buildParsedCommandFromRoot,
|
||||
type IParsedCommand,
|
||||
ParsedCommand,
|
||||
} from 'src/utils/bash/ParsedCommand.js'
|
||||
import { type Node, PARSE_ABORTED } from 'src/utils/bash/parser.js'
|
||||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||||
import type { PermissionUpdate } from 'src/utils/permissions/PermissionUpdateSchema.js'
|
||||
import { createPermissionRequestMessage } from 'src/utils/permissions/permissions.js'
|
||||
import { BashTool } from './BashTool.js'
|
||||
import { bashCommandIsSafeAsync_DEPRECATED } from './bashSecurity.js'
|
||||
|
||||
export type CommandIdentityCheckers = {
|
||||
isNormalizedCdCommand: (command: string) => boolean
|
||||
isNormalizedGitCommand: (command: string) => boolean
|
||||
}
|
||||
|
||||
async function segmentedCommandPermissionResult(
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
segments: string[],
|
||||
bashToolHasPermissionFn: (
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
) => Promise<PermissionResult>,
|
||||
checkers: CommandIdentityCheckers,
|
||||
): Promise<PermissionResult> {
|
||||
// Check for multiple cd commands across all segments
|
||||
const cdCommands = segments.filter(segment => {
|
||||
const trimmed = segment.trim()
|
||||
return checkers.isNormalizedCdCommand(trimmed)
|
||||
})
|
||||
if (cdCommands.length > 1) {
|
||||
const decisionReason = {
|
||||
type: 'other' as const,
|
||||
reason:
|
||||
'Multiple directory changes in one command require approval for clarity',
|
||||
}
|
||||
return {
|
||||
behavior: 'ask',
|
||||
decisionReason,
|
||||
message: createPermissionRequestMessage(BashTool.name, decisionReason),
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY: Check for cd+git across pipe segments to prevent bare repo fsmonitor bypass.
|
||||
// When cd and git are in different pipe segments (e.g., "cd sub && echo | git status"),
|
||||
// each segment is checked independently and neither triggers the cd+git check in
|
||||
// bashPermissions.ts. We must detect this cross-segment pattern here.
|
||||
// Each pipe segment can itself be a compound command (e.g., "cd sub && echo"),
|
||||
// so we split each segment into subcommands before checking.
|
||||
{
|
||||
let hasCd = false
|
||||
let hasGit = false
|
||||
for (const segment of segments) {
|
||||
const subcommands = splitCommand_DEPRECATED(segment)
|
||||
for (const sub of subcommands) {
|
||||
const trimmed = sub.trim()
|
||||
if (checkers.isNormalizedCdCommand(trimmed)) {
|
||||
hasCd = true
|
||||
}
|
||||
if (checkers.isNormalizedGitCommand(trimmed)) {
|
||||
hasGit = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasCd && hasGit) {
|
||||
const decisionReason = {
|
||||
type: 'other' as const,
|
||||
reason:
|
||||
'Compound commands with cd and git require approval to prevent bare repository attacks',
|
||||
}
|
||||
return {
|
||||
behavior: 'ask',
|
||||
decisionReason,
|
||||
message: createPermissionRequestMessage(BashTool.name, decisionReason),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const segmentResults = new Map<string, PermissionResult>()
|
||||
|
||||
// Check each segment through the full permission system
|
||||
for (const segment of segments) {
|
||||
const trimmedSegment = segment.trim()
|
||||
if (!trimmedSegment) continue // Skip empty segments
|
||||
|
||||
const segmentResult = await bashToolHasPermissionFn({
|
||||
...input,
|
||||
command: trimmedSegment,
|
||||
})
|
||||
segmentResults.set(trimmedSegment, segmentResult)
|
||||
}
|
||||
|
||||
// Check if any segment is denied (after evaluating all)
|
||||
const deniedSegment = Array.from(segmentResults.entries()).find(
|
||||
([, result]) => result.behavior === 'deny',
|
||||
)
|
||||
|
||||
if (deniedSegment) {
|
||||
const [segmentCommand, segmentResult] = deniedSegment
|
||||
return {
|
||||
behavior: 'deny',
|
||||
message:
|
||||
segmentResult.behavior === 'deny'
|
||||
? segmentResult.message
|
||||
: `Permission denied for: ${segmentCommand}`,
|
||||
decisionReason: {
|
||||
type: 'subcommandResults',
|
||||
reasons: segmentResults,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const allAllowed = Array.from(segmentResults.values()).every(
|
||||
result => result.behavior === 'allow',
|
||||
)
|
||||
|
||||
if (allAllowed) {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: input,
|
||||
decisionReason: {
|
||||
type: 'subcommandResults',
|
||||
reasons: segmentResults,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Collect suggestions from segments that need approval
|
||||
const suggestions: PermissionUpdate[] = []
|
||||
for (const [, result] of segmentResults) {
|
||||
if (
|
||||
result.behavior !== 'allow' &&
|
||||
'suggestions' in result &&
|
||||
result.suggestions
|
||||
) {
|
||||
suggestions.push(...result.suggestions)
|
||||
}
|
||||
}
|
||||
|
||||
const decisionReason = {
|
||||
type: 'subcommandResults' as const,
|
||||
reasons: segmentResults,
|
||||
}
|
||||
|
||||
return {
|
||||
behavior: 'ask',
|
||||
message: createPermissionRequestMessage(BashTool.name, decisionReason),
|
||||
decisionReason,
|
||||
suggestions: suggestions.length > 0 ? suggestions : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a command segment, stripping output redirections to avoid
|
||||
* treating filenames as commands in permission checking.
|
||||
* Uses ParsedCommand to preserve original quoting.
|
||||
*/
|
||||
async function buildSegmentWithoutRedirections(
|
||||
segmentCommand: string,
|
||||
): Promise<string> {
|
||||
// Fast path: skip parsing if no redirection operators present
|
||||
if (!segmentCommand.includes('>')) {
|
||||
return segmentCommand
|
||||
}
|
||||
|
||||
// Use ParsedCommand to strip redirections while preserving quotes
|
||||
const parsed = await ParsedCommand.parse(segmentCommand)
|
||||
return parsed?.withoutOutputRedirections() ?? segmentCommand
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper that resolves an IParsedCommand (from a pre-parsed AST root if
|
||||
* available, else via ParsedCommand.parse) and delegates to
|
||||
* bashToolCheckCommandOperatorPermissions.
|
||||
*/
|
||||
export async function checkCommandOperatorPermissions(
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
bashToolHasPermissionFn: (
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
) => Promise<PermissionResult>,
|
||||
checkers: CommandIdentityCheckers,
|
||||
astRoot: Node | null | typeof PARSE_ABORTED,
|
||||
): Promise<PermissionResult> {
|
||||
const parsed =
|
||||
astRoot && astRoot !== PARSE_ABORTED
|
||||
? buildParsedCommandFromRoot(input.command, astRoot)
|
||||
: await ParsedCommand.parse(input.command)
|
||||
if (!parsed) {
|
||||
return { behavior: 'passthrough', message: 'Failed to parse command' }
|
||||
}
|
||||
return bashToolCheckCommandOperatorPermissions(
|
||||
input,
|
||||
bashToolHasPermissionFn,
|
||||
checkers,
|
||||
parsed,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the command has special operators that require behavior beyond
|
||||
* simple subcommand checking.
|
||||
*/
|
||||
async function bashToolCheckCommandOperatorPermissions(
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
bashToolHasPermissionFn: (
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
) => Promise<PermissionResult>,
|
||||
checkers: CommandIdentityCheckers,
|
||||
parsed: IParsedCommand,
|
||||
): Promise<PermissionResult> {
|
||||
// 1. Check for unsafe compound commands (subshells, command groups).
|
||||
const tsAnalysis = parsed.getTreeSitterAnalysis()
|
||||
const isUnsafeCompound = tsAnalysis
|
||||
? tsAnalysis.compoundStructure.hasSubshell ||
|
||||
tsAnalysis.compoundStructure.hasCommandGroup
|
||||
: isUnsafeCompoundCommand_DEPRECATED(input.command)
|
||||
if (isUnsafeCompound) {
|
||||
// This command contains an operator like `>` that we don't support as a subcommand separator
|
||||
// Check if bashCommandIsSafe_DEPRECATED has a more specific message
|
||||
const safetyResult = await bashCommandIsSafeAsync_DEPRECATED(input.command)
|
||||
|
||||
const decisionReason = {
|
||||
type: 'other' as const,
|
||||
reason:
|
||||
safetyResult.behavior === 'ask' && safetyResult.message
|
||||
? safetyResult.message
|
||||
: 'This command uses shell operators that require approval for safety',
|
||||
}
|
||||
return {
|
||||
behavior: 'ask',
|
||||
message: createPermissionRequestMessage(BashTool.name, decisionReason),
|
||||
decisionReason,
|
||||
// This is an unsafe compound command, so we don't want to suggest rules since we wont be able to allow it
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check for piped commands using ParsedCommand (preserves quotes)
|
||||
const pipeSegments = parsed.getPipeSegments()
|
||||
|
||||
// If no pipes (single segment), let normal flow handle it
|
||||
if (pipeSegments.length <= 1) {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'No pipes found in command',
|
||||
}
|
||||
}
|
||||
|
||||
// Strip output redirections from each segment while preserving quotes
|
||||
const segments = await Promise.all(
|
||||
pipeSegments.map(segment => buildSegmentWithoutRedirections(segment)),
|
||||
)
|
||||
|
||||
// Handle as segmented command
|
||||
return segmentedCommandPermissionResult(
|
||||
input,
|
||||
segments,
|
||||
bashToolHasPermissionFn,
|
||||
checkers,
|
||||
)
|
||||
}
|
||||
2621
packages/builtin-tools/src/tools/BashTool/bashPermissions.ts
Normal file
2621
packages/builtin-tools/src/tools/BashTool/bashPermissions.ts
Normal file
File diff suppressed because it is too large
Load Diff
2592
packages/builtin-tools/src/tools/BashTool/bashSecurity.ts
Normal file
2592
packages/builtin-tools/src/tools/BashTool/bashSecurity.ts
Normal file
File diff suppressed because it is too large
Load Diff
140
packages/builtin-tools/src/tools/BashTool/commandSemantics.ts
Normal file
140
packages/builtin-tools/src/tools/BashTool/commandSemantics.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Command semantics configuration for interpreting exit codes in different contexts.
|
||||
*
|
||||
* Many commands use exit codes to convey information other than just success/failure.
|
||||
* For example, grep returns 1 when no matches are found, which is not an error condition.
|
||||
*/
|
||||
|
||||
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
|
||||
|
||||
export type CommandSemantic = (
|
||||
exitCode: number,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
) => {
|
||||
isError: boolean
|
||||
message?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Default semantic: treat only 0 as success, everything else as error
|
||||
*/
|
||||
const DEFAULT_SEMANTIC: CommandSemantic = (exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode !== 0,
|
||||
message:
|
||||
exitCode !== 0 ? `Command failed with exit code ${exitCode}` : undefined,
|
||||
})
|
||||
|
||||
/**
|
||||
* Command-specific semantics
|
||||
*/
|
||||
const COMMAND_SEMANTICS: Map<string, CommandSemantic> = new Map([
|
||||
// grep: 0=matches found, 1=no matches, 2+=error
|
||||
[
|
||||
'grep',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message: exitCode === 1 ? 'No matches found' : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
// ripgrep has same semantics as grep
|
||||
[
|
||||
'rg',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message: exitCode === 1 ? 'No matches found' : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
// find: 0=success, 1=partial success (some dirs inaccessible), 2+=error
|
||||
[
|
||||
'find',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message:
|
||||
exitCode === 1 ? 'Some directories were inaccessible' : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
// diff: 0=no differences, 1=differences found, 2+=error
|
||||
[
|
||||
'diff',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message: exitCode === 1 ? 'Files differ' : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
// test/[: 0=condition true, 1=condition false, 2+=error
|
||||
[
|
||||
'test',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message: exitCode === 1 ? 'Condition is false' : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
// [ is an alias for test
|
||||
[
|
||||
'[',
|
||||
(exitCode, _stdout, _stderr) => ({
|
||||
isError: exitCode >= 2,
|
||||
message: exitCode === 1 ? 'Condition is false' : undefined,
|
||||
}),
|
||||
],
|
||||
|
||||
// wc, head, tail, cat, etc.: these typically only fail on real errors
|
||||
// so we use default semantics
|
||||
])
|
||||
|
||||
/**
|
||||
* Get the semantic interpretation for a command
|
||||
*/
|
||||
function getCommandSemantic(command: string): CommandSemantic {
|
||||
// Extract the base command (first word, handling pipes)
|
||||
const baseCommand = heuristicallyExtractBaseCommand(command)
|
||||
const semantic = COMMAND_SEMANTICS.get(baseCommand)
|
||||
return semantic !== undefined ? semantic : DEFAULT_SEMANTIC
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract just the command name (first word) from a single command string.
|
||||
*/
|
||||
function extractBaseCommand(command: string): string {
|
||||
return command.trim().split(/\s+/)[0] || ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the primary command from a complex command line;
|
||||
* May get it super wrong - don't depend on this for security
|
||||
*/
|
||||
function heuristicallyExtractBaseCommand(command: string): string {
|
||||
const segments = splitCommand_DEPRECATED(command)
|
||||
|
||||
// Take the last command as that's what determines the exit code
|
||||
const lastCommand = segments[segments.length - 1] || command
|
||||
|
||||
return extractBaseCommand(lastCommand)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret command result based on semantic rules
|
||||
*/
|
||||
export function interpretCommandResult(
|
||||
command: string,
|
||||
exitCode: number,
|
||||
stdout: string,
|
||||
stderr: string,
|
||||
): {
|
||||
isError: boolean
|
||||
message?: string
|
||||
} {
|
||||
const semantic = getCommandSemantic(command)
|
||||
const result = semantic(exitCode, stdout, stderr)
|
||||
|
||||
return {
|
||||
isError: result.isError,
|
||||
message: result.message,
|
||||
}
|
||||
}
|
||||
13
packages/builtin-tools/src/tools/BashTool/commentLabel.ts
Normal file
13
packages/builtin-tools/src/tools/BashTool/commentLabel.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* If the first line of a bash command is a `# comment` (not a `#!` shebang),
|
||||
* return the comment text stripped of the `#` prefix. Otherwise undefined.
|
||||
*
|
||||
* Under fullscreen mode this is the non-verbose tool-use label AND the
|
||||
* collapse-group ⎿ hint — it's what Claude wrote for the human to read.
|
||||
*/
|
||||
export function extractBashCommentLabel(command: string): string | undefined {
|
||||
const nl = command.indexOf('\n')
|
||||
const firstLine = (nl === -1 ? command : command.slice(0, nl)).trim()
|
||||
if (!firstLine.startsWith('#') || firstLine.startsWith('#!')) return undefined
|
||||
return firstLine.replace(/^#+\s*/, '') || undefined
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Detects potentially destructive bash commands and returns a warning string
|
||||
* for display in the permission dialog. This is purely informational — it
|
||||
* doesn't affect permission logic or auto-approval.
|
||||
*/
|
||||
|
||||
type DestructivePattern = {
|
||||
pattern: RegExp
|
||||
warning: string
|
||||
}
|
||||
|
||||
const DESTRUCTIVE_PATTERNS: DestructivePattern[] = [
|
||||
// Git — data loss / hard to reverse
|
||||
{
|
||||
pattern: /\bgit\s+reset\s+--hard\b/,
|
||||
warning: 'Note: may discard uncommitted changes',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+push\b[^;&|\n]*[ \t](--force|--force-with-lease|-f)\b/,
|
||||
warning: 'Note: may overwrite remote history',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\bgit\s+clean\b(?![^;&|\n]*(?:-[a-zA-Z]*n|--dry-run))[^;&|\n]*-[a-zA-Z]*f/,
|
||||
warning: 'Note: may permanently delete untracked files',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+checkout\s+(--\s+)?\.[ \t]*($|[;&|\n])/,
|
||||
warning: 'Note: may discard all working tree changes',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+restore\s+(--\s+)?\.[ \t]*($|[;&|\n])/,
|
||||
warning: 'Note: may discard all working tree changes',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+stash[ \t]+(drop|clear)\b/,
|
||||
warning: 'Note: may permanently remove stashed changes',
|
||||
},
|
||||
{
|
||||
pattern:
|
||||
/\bgit\s+branch\s+(-D[ \t]|--delete\s+--force|--force\s+--delete)\b/,
|
||||
warning: 'Note: may force-delete a branch',
|
||||
},
|
||||
|
||||
// Git — safety bypass
|
||||
{
|
||||
pattern: /\bgit\s+(commit|push|merge)\b[^;&|\n]*--no-verify\b/,
|
||||
warning: 'Note: may skip safety hooks',
|
||||
},
|
||||
{
|
||||
pattern: /\bgit\s+commit\b[^;&|\n]*--amend\b/,
|
||||
warning: 'Note: may rewrite the last commit',
|
||||
},
|
||||
|
||||
// File deletion (dangerous paths already handled by checkDangerousRemovalPaths)
|
||||
{
|
||||
pattern:
|
||||
/(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR][a-zA-Z]*f|(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f[a-zA-Z]*[rR]/,
|
||||
warning: 'Note: may recursively force-remove files',
|
||||
},
|
||||
{
|
||||
pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*[rR]/,
|
||||
warning: 'Note: may recursively remove files',
|
||||
},
|
||||
{
|
||||
pattern: /(^|[;&|\n]\s*)rm\s+-[a-zA-Z]*f/,
|
||||
warning: 'Note: may force-remove files',
|
||||
},
|
||||
|
||||
// Database
|
||||
{
|
||||
pattern: /\b(DROP|TRUNCATE)\s+(TABLE|DATABASE|SCHEMA)\b/i,
|
||||
warning: 'Note: may drop or truncate database objects',
|
||||
},
|
||||
{
|
||||
pattern: /\bDELETE\s+FROM\s+\w+[ \t]*(;|"|'|\n|$)/i,
|
||||
warning: 'Note: may delete all rows from a database table',
|
||||
},
|
||||
|
||||
// Infrastructure
|
||||
{
|
||||
pattern: /\bkubectl\s+delete\b/,
|
||||
warning: 'Note: may delete Kubernetes resources',
|
||||
},
|
||||
{
|
||||
pattern: /\bterraform\s+destroy\b/,
|
||||
warning: 'Note: may destroy Terraform infrastructure',
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
* Checks if a bash command matches known destructive patterns.
|
||||
* Returns a human-readable warning string, or null if no destructive pattern is detected.
|
||||
*/
|
||||
export function getDestructiveCommandWarning(command: string): string | null {
|
||||
for (const { pattern, warning } of DESTRUCTIVE_PATTERNS) {
|
||||
if (pattern.test(command)) {
|
||||
return warning
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
115
packages/builtin-tools/src/tools/BashTool/modeValidation.ts
Normal file
115
packages/builtin-tools/src/tools/BashTool/modeValidation.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { z } from 'zod/v4'
|
||||
import type { ToolPermissionContext } from 'src/Tool.js'
|
||||
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
|
||||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||||
import type { BashTool } from './BashTool.js'
|
||||
|
||||
const ACCEPT_EDITS_ALLOWED_COMMANDS = [
|
||||
'mkdir',
|
||||
'touch',
|
||||
'rm',
|
||||
'rmdir',
|
||||
'mv',
|
||||
'cp',
|
||||
'sed',
|
||||
] as const
|
||||
|
||||
type FilesystemCommand = (typeof ACCEPT_EDITS_ALLOWED_COMMANDS)[number]
|
||||
|
||||
function isFilesystemCommand(command: string): command is FilesystemCommand {
|
||||
return ACCEPT_EDITS_ALLOWED_COMMANDS.includes(command as FilesystemCommand)
|
||||
}
|
||||
|
||||
function validateCommandForMode(
|
||||
cmd: string,
|
||||
toolPermissionContext: ToolPermissionContext,
|
||||
): PermissionResult {
|
||||
const trimmedCmd = cmd.trim()
|
||||
const [baseCmd] = trimmedCmd.split(/\s+/)
|
||||
|
||||
if (!baseCmd) {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'Base command not found',
|
||||
}
|
||||
}
|
||||
|
||||
// In Accept Edits mode, auto-allow filesystem operations
|
||||
if (
|
||||
toolPermissionContext.mode === 'acceptEdits' &&
|
||||
isFilesystemCommand(baseCmd)
|
||||
) {
|
||||
return {
|
||||
behavior: 'allow',
|
||||
updatedInput: { command: cmd },
|
||||
decisionReason: {
|
||||
type: 'mode',
|
||||
mode: 'acceptEdits',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: `No mode-specific handling for '${baseCmd}' in ${toolPermissionContext.mode} mode`,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if commands should be handled differently based on the current permission mode
|
||||
*
|
||||
* This is the main entry point for mode-based permission logic.
|
||||
* Currently handles Accept Edits mode for filesystem commands,
|
||||
* but designed to be extended for other modes.
|
||||
*
|
||||
* @param input - The bash command input
|
||||
* @param toolPermissionContext - Context containing mode and permissions
|
||||
* @returns
|
||||
* - 'allow' if the current mode permits auto-approval
|
||||
* - 'ask' if the command needs approval in current mode
|
||||
* - 'passthrough' if no mode-specific handling applies
|
||||
*/
|
||||
export function checkPermissionMode(
|
||||
input: z.infer<typeof BashTool.inputSchema>,
|
||||
toolPermissionContext: ToolPermissionContext,
|
||||
): PermissionResult {
|
||||
// Skip if in bypass mode (handled elsewhere)
|
||||
if (toolPermissionContext.mode === 'bypassPermissions') {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'Bypass mode is handled in main permission flow',
|
||||
}
|
||||
}
|
||||
|
||||
// Skip if in dontAsk mode (handled in main permission flow)
|
||||
if (toolPermissionContext.mode === 'dontAsk') {
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'DontAsk mode is handled in main permission flow',
|
||||
}
|
||||
}
|
||||
|
||||
const commands = splitCommand_DEPRECATED(input.command)
|
||||
|
||||
// Check each subcommand
|
||||
for (const cmd of commands) {
|
||||
const result = validateCommandForMode(cmd, toolPermissionContext)
|
||||
|
||||
// If any command triggers mode-specific behavior, return that result
|
||||
if (result.behavior !== 'passthrough') {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// No mode-specific handling needed
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'No mode-specific validation required',
|
||||
}
|
||||
}
|
||||
|
||||
export function getAutoAllowedCommands(
|
||||
mode: ToolPermissionContext['mode'],
|
||||
): readonly string[] {
|
||||
return mode === 'acceptEdits' ? ACCEPT_EDITS_ALLOWED_COMMANDS : []
|
||||
}
|
||||
1303
packages/builtin-tools/src/tools/BashTool/pathValidation.ts
Normal file
1303
packages/builtin-tools/src/tools/BashTool/pathValidation.ts
Normal file
File diff suppressed because it is too large
Load Diff
369
packages/builtin-tools/src/tools/BashTool/prompt.ts
Normal file
369
packages/builtin-tools/src/tools/BashTool/prompt.ts
Normal file
@@ -0,0 +1,369 @@
|
||||
import { feature } from 'bun:bundle'
|
||||
import { prependBullets } from 'src/constants/prompts.js'
|
||||
import { getAttributionTexts } from 'src/utils/attribution.js'
|
||||
import { hasEmbeddedSearchTools } from 'src/utils/embeddedTools.js'
|
||||
import { isEnvTruthy } from 'src/utils/envUtils.js'
|
||||
import { shouldIncludeGitInstructions } from 'src/utils/gitSettings.js'
|
||||
import { getClaudeTempDir } from 'src/utils/permissions/filesystem.js'
|
||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
|
||||
import { jsonStringify } from 'src/utils/slowOperations.js'
|
||||
import {
|
||||
getDefaultBashTimeoutMs,
|
||||
getMaxBashTimeoutMs,
|
||||
} from 'src/utils/timeouts.js'
|
||||
import {
|
||||
getUndercoverInstructions,
|
||||
isUndercover,
|
||||
} from 'src/utils/undercover.js'
|
||||
import { AGENT_TOOL_NAME } from '../AgentTool/constants.js'
|
||||
import { FILE_EDIT_TOOL_NAME } from '../FileEditTool/constants.js'
|
||||
import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'
|
||||
import { FILE_WRITE_TOOL_NAME } from '../FileWriteTool/prompt.js'
|
||||
import { GLOB_TOOL_NAME } from '../GlobTool/prompt.js'
|
||||
import { GREP_TOOL_NAME } from '../GrepTool/prompt.js'
|
||||
import { TodoWriteTool } from '../TodoWriteTool/TodoWriteTool.js'
|
||||
import { BASH_TOOL_NAME } from './toolName.js'
|
||||
|
||||
export function getDefaultTimeoutMs(): number {
|
||||
return getDefaultBashTimeoutMs()
|
||||
}
|
||||
|
||||
export function getMaxTimeoutMs(): number {
|
||||
return getMaxBashTimeoutMs()
|
||||
}
|
||||
|
||||
function getBackgroundUsageNote(): string | null {
|
||||
if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) {
|
||||
return null
|
||||
}
|
||||
return "You can use the `run_in_background` parameter to run the command in the background. Only use this if you don't need the result immediately and are OK being notified when the command completes later. You do not need to check the output right away - you'll be notified when it finishes. You do not need to use '&' at the end of the command when using this parameter."
|
||||
}
|
||||
|
||||
function getCommitAndPRInstructions(): string {
|
||||
// Defense-in-depth: undercover instructions must survive even if the user
|
||||
// has disabled git instructions entirely. Attribution stripping and model-ID
|
||||
// hiding are mechanical and work regardless, but the explicit "don't blow
|
||||
// your cover" instructions are the last line of defense against the model
|
||||
// volunteering an internal codename in a commit message.
|
||||
const undercoverSection =
|
||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
||||
? getUndercoverInstructions() + '\n'
|
||||
: ''
|
||||
|
||||
if (!shouldIncludeGitInstructions()) return undercoverSection
|
||||
|
||||
// For ant users, use the short version pointing to skills
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const skillsSection = !isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
|
||||
? `For git commits and pull requests, use the \`/commit\` and \`/commit-push-pr\` skills:
|
||||
- \`/commit\` - Create a git commit with staged changes
|
||||
- \`/commit-push-pr\` - Commit, push, and create a pull request
|
||||
|
||||
These skills handle git safety protocols, proper commit message formatting, and PR creation.
|
||||
|
||||
Before creating a pull request, run \`/simplify\` to review your changes, then test end-to-end (e.g. via \`/tmux\` for interactive features).
|
||||
|
||||
`
|
||||
: ''
|
||||
return `${undercoverSection}# Git operations
|
||||
|
||||
${skillsSection}IMPORTANT: NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it.
|
||||
|
||||
Use the gh command via the Bash tool for other GitHub-related tasks including working with issues, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
# Other common operations
|
||||
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments`
|
||||
}
|
||||
|
||||
// For external users, include full inline instructions
|
||||
const { commit: commitAttribution, pr: prAttribution } = getAttributionTexts()
|
||||
|
||||
return `# Committing changes with git
|
||||
|
||||
Only create commits when requested by the user. If unclear, ask first. When the user asks you to create a new git commit, follow these steps carefully:
|
||||
|
||||
You can call multiple tools in a single response. When multiple independent pieces of information are requested and all commands are likely to succeed, run multiple tool calls in parallel for optimal performance. The numbered steps below indicate which commands should be batched in parallel.
|
||||
|
||||
Git Safety Protocol:
|
||||
- NEVER update the git config
|
||||
- NEVER run destructive git commands (push --force, reset --hard, checkout ., restore ., clean -f, branch -D) unless the user explicitly requests these actions. Taking unauthorized destructive actions is unhelpful and can result in lost work, so it's best to ONLY run these commands when given direct instructions
|
||||
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
|
||||
- NEVER run force push to main/master, warn the user if they request it
|
||||
- CRITICAL: Always create NEW commits rather than amending, unless the user explicitly requests a git amend. When a pre-commit hook fails, the commit did NOT happen — so --amend would modify the PREVIOUS commit, which may result in destroying work or losing previous changes. Instead, after hook failure, fix the issue, re-stage, and create a NEW commit
|
||||
- When staging files, prefer adding specific files by name rather than using "git add -A" or "git add .", which can accidentally include sensitive files (.env, credentials) or large binaries
|
||||
- NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive
|
||||
|
||||
1. Run the following bash commands in parallel, each using the ${BASH_TOOL_NAME} tool:
|
||||
- Run a git status command to see all untracked files. IMPORTANT: Never use the -uall flag as it can cause memory issues on large repos.
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed.
|
||||
- Run a git log command to see recent commit messages, so that you can follow this repository's commit message style.
|
||||
2. Analyze all staged changes (both previously staged and newly added) and draft a commit message:
|
||||
- Summarize the nature of the changes (eg. new feature, enhancement to an existing feature, bug fix, refactoring, test, docs, etc.). Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.).
|
||||
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
|
||||
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
|
||||
- Ensure it accurately reflects the changes and their purpose
|
||||
3. Run the following commands in parallel:
|
||||
- Add relevant untracked files to the staging area.
|
||||
- Create the commit with a message${commitAttribution ? ` ending with:\n ${commitAttribution}` : '.'}
|
||||
- Run git status after the commit completes to verify success.
|
||||
Note: git status depends on the commit completing, so run it sequentially after the commit.
|
||||
4. If the commit fails due to pre-commit hook: fix the issue and create a NEW commit
|
||||
|
||||
Important notes:
|
||||
- NEVER run additional commands to read or explore code, besides git bash commands
|
||||
- NEVER use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools
|
||||
- DO NOT push to the remote repository unless the user explicitly asks you to do so
|
||||
- IMPORTANT: Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported.
|
||||
- IMPORTANT: Do not use --no-edit with git rebase commands, as the --no-edit flag is not a valid option for git rebase.
|
||||
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
|
||||
- In order to ensure good formatting, ALWAYS pass the commit message via a HEREDOC, a la this example:
|
||||
<example>
|
||||
git commit -m "$(cat <<'EOF'
|
||||
Commit message here.${commitAttribution ? `\n\n ${commitAttribution}` : ''}
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
# Creating pull requests
|
||||
Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed.
|
||||
|
||||
IMPORTANT: When the user asks you to create a pull request, follow these steps carefully:
|
||||
|
||||
1. Run the following bash commands in parallel using the ${BASH_TOOL_NAME} tool, in order to understand the current state of the branch since it diverged from the main branch:
|
||||
- Run a git status command to see all untracked files (never use -uall flag)
|
||||
- Run a git diff command to see both staged and unstaged changes that will be committed
|
||||
- Check if the current branch tracks a remote branch and is up to date with the remote, so you know if you need to push to the remote
|
||||
- Run a git log command and \`git diff [base-branch]...HEAD\` to understand the full commit history for the current branch (from the time it diverged from the base branch)
|
||||
2. Analyze all changes that will be included in the pull request, making sure to look at all relevant commits (NOT just the latest commit, but ALL commits that will be included in the pull request!!!), and draft a pull request title and summary:
|
||||
- Keep the PR title short (under 70 characters)
|
||||
- Use the description/body for details, not the title
|
||||
3. Run the following commands in parallel:
|
||||
- Create new branch if needed
|
||||
- Push to remote with -u flag if needed
|
||||
- Create PR using gh pr create with the format below. Use a HEREDOC to pass the body to ensure correct formatting.
|
||||
<example>
|
||||
gh pr create --title "the pr title" --body "$(cat <<'EOF'
|
||||
## Summary
|
||||
<1-3 bullet points>
|
||||
|
||||
## Test plan
|
||||
[Bulleted markdown checklist of TODOs for testing the pull request...]${prAttribution ? `\n\n${prAttribution}` : ''}
|
||||
EOF
|
||||
)"
|
||||
</example>
|
||||
|
||||
Important:
|
||||
- DO NOT use the ${TodoWriteTool.name} or ${AGENT_TOOL_NAME} tools
|
||||
- Return the PR URL when you're done, so the user can see it
|
||||
|
||||
# Other common operations
|
||||
- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments`
|
||||
}
|
||||
|
||||
// SandboxManager merges config from multiple sources (settings layers, defaults,
|
||||
// CLI flags) without deduping, so paths like ~/.cache appear 3× in allowOnly.
|
||||
// Dedup here before inlining into the prompt — affects only what the model sees,
|
||||
// not sandbox enforcement. Saves ~150-200 tokens/request when sandbox is enabled.
|
||||
function dedup<T>(arr: T[] | undefined): T[] | undefined {
|
||||
if (!arr || arr.length === 0) return arr
|
||||
return [...new Set(arr)]
|
||||
}
|
||||
|
||||
function getSimpleSandboxSection(): string {
|
||||
if (!SandboxManager.isSandboxingEnabled()) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const fsReadConfig = SandboxManager.getFsReadConfig()
|
||||
const fsWriteConfig = SandboxManager.getFsWriteConfig()
|
||||
const networkRestrictionConfig = SandboxManager.getNetworkRestrictionConfig()
|
||||
const allowUnixSockets = SandboxManager.getAllowUnixSockets()
|
||||
const ignoreViolations = SandboxManager.getIgnoreViolations()
|
||||
const allowUnsandboxedCommands =
|
||||
SandboxManager.areUnsandboxedCommandsAllowed()
|
||||
|
||||
// Replace the per-UID temp dir literal (e.g. /private/tmp/claude-1001/) with
|
||||
// "$TMPDIR" so the prompt is identical across users — avoids busting the
|
||||
// cross-user global prompt cache. The sandbox already sets $TMPDIR at runtime.
|
||||
const claudeTempDir = getClaudeTempDir()
|
||||
const normalizeAllowOnly = (paths: string[]): string[] =>
|
||||
[...new Set(paths)].map(p => (p === claudeTempDir ? '$TMPDIR' : p))
|
||||
|
||||
const filesystemConfig = {
|
||||
read: {
|
||||
denyOnly: dedup(fsReadConfig.denyOnly),
|
||||
...(fsReadConfig.allowWithinDeny && {
|
||||
allowWithinDeny: dedup(fsReadConfig.allowWithinDeny),
|
||||
}),
|
||||
},
|
||||
write: {
|
||||
allowOnly: normalizeAllowOnly(fsWriteConfig.allowOnly),
|
||||
denyWithinAllow: dedup(fsWriteConfig.denyWithinAllow),
|
||||
},
|
||||
}
|
||||
|
||||
const networkConfig = {
|
||||
...(networkRestrictionConfig?.allowedHosts && {
|
||||
allowedHosts: dedup(networkRestrictionConfig.allowedHosts),
|
||||
}),
|
||||
...(networkRestrictionConfig?.deniedHosts && {
|
||||
deniedHosts: dedup(networkRestrictionConfig.deniedHosts),
|
||||
}),
|
||||
...(allowUnixSockets && { allowUnixSockets: dedup(allowUnixSockets) }),
|
||||
}
|
||||
|
||||
const restrictionsLines = []
|
||||
if (Object.keys(filesystemConfig).length > 0) {
|
||||
restrictionsLines.push(`Filesystem: ${jsonStringify(filesystemConfig)}`)
|
||||
}
|
||||
if (Object.keys(networkConfig).length > 0) {
|
||||
restrictionsLines.push(`Network: ${jsonStringify(networkConfig)}`)
|
||||
}
|
||||
if (ignoreViolations) {
|
||||
restrictionsLines.push(
|
||||
`Ignored violations: ${jsonStringify(ignoreViolations)}`,
|
||||
)
|
||||
}
|
||||
|
||||
const sandboxOverrideItems: Array<string | string[]> =
|
||||
allowUnsandboxedCommands
|
||||
? [
|
||||
'You should always default to running commands within the sandbox. Do NOT attempt to set `dangerouslyDisableSandbox: true` unless:',
|
||||
[
|
||||
'The user *explicitly* asks you to bypass sandbox',
|
||||
'A specific command just failed and you see evidence of sandbox restrictions causing the failure. Note that commands can fail for many reasons unrelated to the sandbox (missing files, wrong arguments, network issues, etc.).',
|
||||
],
|
||||
'Evidence of sandbox-caused failures includes:',
|
||||
[
|
||||
'"Operation not permitted" errors for file/network operations',
|
||||
'Access denied to specific paths outside allowed directories',
|
||||
'Network connection failures to non-whitelisted hosts',
|
||||
'Unix socket connection errors',
|
||||
],
|
||||
'When you see evidence of sandbox-caused failure:',
|
||||
[
|
||||
"Immediately retry with `dangerouslyDisableSandbox: true` (don't ask, just do it)",
|
||||
'Briefly explain what sandbox restriction likely caused the failure. Be sure to mention that the user can use the `/sandbox` command to manage restrictions.',
|
||||
'This will prompt the user for permission',
|
||||
],
|
||||
'Treat each command you execute with `dangerouslyDisableSandbox: true` individually. Even if you have recently run a command with this setting, you should default to running future commands within the sandbox.',
|
||||
'Do not suggest adding sensitive paths like ~/.bashrc, ~/.zshrc, ~/.ssh/*, or credential files to the sandbox allowlist.',
|
||||
]
|
||||
: [
|
||||
'All commands MUST run in sandbox mode - the `dangerouslyDisableSandbox` parameter is disabled by policy.',
|
||||
'Commands cannot run outside the sandbox under any circumstances.',
|
||||
'If a command fails due to sandbox restrictions, work with the user to adjust sandbox settings instead.',
|
||||
]
|
||||
|
||||
const items: Array<string | string[]> = [
|
||||
...sandboxOverrideItems,
|
||||
'For temporary files, always use the `$TMPDIR` environment variable. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use `/tmp` directly - use `$TMPDIR` instead.',
|
||||
]
|
||||
|
||||
return [
|
||||
'',
|
||||
'## Command sandbox',
|
||||
'By default, your command will be run in a sandbox. This sandbox controls which directories and network hosts commands may access or modify without an explicit override.',
|
||||
'',
|
||||
'The sandbox has the following restrictions:',
|
||||
restrictionsLines.join('\n'),
|
||||
'',
|
||||
...prependBullets(items),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function getSimplePrompt(): string {
|
||||
// Ant-native builds alias find/grep to embedded bfs/ugrep in Claude's shell,
|
||||
// so we don't steer away from them (and Glob/Grep tools are removed).
|
||||
const embedded = hasEmbeddedSearchTools()
|
||||
|
||||
const toolPreferenceItems = [
|
||||
...(embedded
|
||||
? []
|
||||
: [
|
||||
`File search: Use ${GLOB_TOOL_NAME} (NOT find or ls)`,
|
||||
`Content search: Use ${GREP_TOOL_NAME} (NOT grep or rg)`,
|
||||
]),
|
||||
`Read files: Use ${FILE_READ_TOOL_NAME} (NOT cat/head/tail)`,
|
||||
`Edit files: Use ${FILE_EDIT_TOOL_NAME} (NOT sed/awk)`,
|
||||
`Write files: Use ${FILE_WRITE_TOOL_NAME} (NOT echo >/cat <<EOF)`,
|
||||
'Communication: Output text directly (NOT echo/printf)',
|
||||
]
|
||||
|
||||
const avoidCommands = embedded
|
||||
? '`cat`, `head`, `tail`, `sed`, `awk`, or `echo`'
|
||||
: '`find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo`'
|
||||
|
||||
const multipleCommandsSubitems = [
|
||||
`If the commands are independent and can run in parallel, make multiple ${BASH_TOOL_NAME} tool calls in a single message. Example: if you need to run "git status" and "git diff", send a single message with two ${BASH_TOOL_NAME} tool calls in parallel.`,
|
||||
`If the commands depend on each other and must run sequentially, use a single ${BASH_TOOL_NAME} call with '&&' to chain them together.`,
|
||||
"Use ';' only when you need to run commands sequentially but don't care if earlier commands fail.",
|
||||
'DO NOT use newlines to separate commands (newlines are ok in quoted strings).',
|
||||
]
|
||||
|
||||
const gitSubitems = [
|
||||
'Prefer to create a new commit rather than amending an existing commit.',
|
||||
'Before running destructive operations (e.g., git reset --hard, git push --force, git checkout --), consider whether there is a safer alternative that achieves the same goal. Only use destructive operations when they are truly the best approach.',
|
||||
'Never skip hooks (--no-verify) or bypass signing (--no-gpg-sign, -c commit.gpgsign=false) unless the user has explicitly asked for it. If a hook fails, investigate and fix the underlying issue.',
|
||||
]
|
||||
|
||||
const sleepSubitems = [
|
||||
'Do not sleep between commands that can run immediately — just run them.',
|
||||
...(feature('MONITOR_TOOL')
|
||||
? [
|
||||
'Use the Monitor tool to stream events from a background process (each stdout line is a notification). For one-shot "wait until done," use Bash with run_in_background instead.',
|
||||
]
|
||||
: []),
|
||||
'If your command is long running and you would like to be notified when it finishes — use `run_in_background`. No sleep needed.',
|
||||
'Do not retry failing commands in a sleep loop — diagnose the root cause.',
|
||||
'If waiting for a background task you started with `run_in_background`, you will be notified when it completes — do not poll.',
|
||||
...(feature('MONITOR_TOOL')
|
||||
? [
|
||||
'`sleep N` as the first command with N ≥ 2 is blocked. If you need a delay (rate limiting, deliberate pacing), keep it under 2 seconds.',
|
||||
]
|
||||
: [
|
||||
'If you must poll an external process, use a check command (e.g. `gh run view`) rather than sleeping first.',
|
||||
'If you must sleep, keep the duration short (1-5 seconds) to avoid blocking the user.',
|
||||
]),
|
||||
]
|
||||
const backgroundNote = getBackgroundUsageNote()
|
||||
|
||||
const instructionItems: Array<string | string[]> = [
|
||||
'If your command will create new directories or files, first use this tool to run `ls` to verify the parent directory exists and is the correct location.',
|
||||
'Always quote file paths that contain spaces with double quotes in your command (e.g., cd "path with spaces/file.txt")',
|
||||
'Try to maintain your current working directory throughout the session by using absolute paths and avoiding usage of `cd`. You may use `cd` if the User explicitly requests it.',
|
||||
`You may specify an optional timeout in milliseconds (up to ${getMaxTimeoutMs()}ms / ${getMaxTimeoutMs() / 60000} minutes). By default, your command will timeout after ${getDefaultTimeoutMs()}ms (${getDefaultTimeoutMs() / 60000} minutes).`,
|
||||
...(backgroundNote !== null ? [backgroundNote] : []),
|
||||
'When issuing multiple commands:',
|
||||
multipleCommandsSubitems,
|
||||
'For git commands:',
|
||||
gitSubitems,
|
||||
'Avoid unnecessary `sleep` commands:',
|
||||
sleepSubitems,
|
||||
...(embedded
|
||||
? [
|
||||
// bfs (which backs `find`) uses Oniguruma for -regex, which picks the
|
||||
// FIRST matching alternative (leftmost-first), unlike GNU find's
|
||||
// POSIX leftmost-longest. This silently drops matches when a shorter
|
||||
// alternative is a prefix of a longer one.
|
||||
"When using `find -regex` with alternation, put the longest alternative first. Example: use `'.*\\.\\(tsx\\|ts\\)'` not `'.*\\.\\(ts\\|tsx\\)'` — the second form silently skips `.tsx` files.",
|
||||
]
|
||||
: []),
|
||||
]
|
||||
|
||||
return [
|
||||
'Executes a given bash command and returns its output.',
|
||||
'',
|
||||
"The working directory persists between commands, but shell state does not. The shell environment is initialized from the user's profile (bash or zsh).",
|
||||
'',
|
||||
`IMPORTANT: Avoid using this tool to run ${avoidCommands} commands, unless explicitly instructed or after you have verified that a dedicated tool cannot accomplish your task. Instead, use the appropriate dedicated tool as this will provide a much better experience for the user:`,
|
||||
'',
|
||||
...prependBullets(toolPreferenceItems),
|
||||
`While the ${BASH_TOOL_NAME} tool can do similar things, it’s better to use the built-in tools as they provide a better user experience and make it easier to review tool calls and give permission.`,
|
||||
'',
|
||||
'# Instructions',
|
||||
...prependBullets(instructionItems),
|
||||
getSimpleSandboxSection(),
|
||||
...(getCommitAndPRInstructions() ? ['', getCommitAndPRInstructions()] : []),
|
||||
].join('\n')
|
||||
}
|
||||
1990
packages/builtin-tools/src/tools/BashTool/readOnlyValidation.ts
Normal file
1990
packages/builtin-tools/src/tools/BashTool/readOnlyValidation.ts
Normal file
File diff suppressed because it is too large
Load Diff
322
packages/builtin-tools/src/tools/BashTool/sedEditParser.ts
Normal file
322
packages/builtin-tools/src/tools/BashTool/sedEditParser.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
/**
|
||||
* Parser for sed edit commands (-i flag substitutions)
|
||||
* Extracts file paths and substitution patterns to enable file-edit-style rendering
|
||||
*/
|
||||
|
||||
import { randomBytes } from 'crypto'
|
||||
import { tryParseShellCommand } from 'src/utils/bash/shellQuote.js'
|
||||
|
||||
// BRE→ERE conversion placeholders (null-byte sentinels, never appear in user input)
|
||||
const BACKSLASH_PLACEHOLDER = '\x00BACKSLASH\x00'
|
||||
const PLUS_PLACEHOLDER = '\x00PLUS\x00'
|
||||
const QUESTION_PLACEHOLDER = '\x00QUESTION\x00'
|
||||
const PIPE_PLACEHOLDER = '\x00PIPE\x00'
|
||||
const LPAREN_PLACEHOLDER = '\x00LPAREN\x00'
|
||||
const RPAREN_PLACEHOLDER = '\x00RPAREN\x00'
|
||||
const BACKSLASH_PLACEHOLDER_RE = new RegExp(BACKSLASH_PLACEHOLDER, 'g')
|
||||
const PLUS_PLACEHOLDER_RE = new RegExp(PLUS_PLACEHOLDER, 'g')
|
||||
const QUESTION_PLACEHOLDER_RE = new RegExp(QUESTION_PLACEHOLDER, 'g')
|
||||
const PIPE_PLACEHOLDER_RE = new RegExp(PIPE_PLACEHOLDER, 'g')
|
||||
const LPAREN_PLACEHOLDER_RE = new RegExp(LPAREN_PLACEHOLDER, 'g')
|
||||
const RPAREN_PLACEHOLDER_RE = new RegExp(RPAREN_PLACEHOLDER, 'g')
|
||||
|
||||
export type SedEditInfo = {
|
||||
/** The file path being edited */
|
||||
filePath: string
|
||||
/** The search pattern (regex) */
|
||||
pattern: string
|
||||
/** The replacement string */
|
||||
replacement: string
|
||||
/** Substitution flags (g, i, etc.) */
|
||||
flags: string
|
||||
/** Whether to use extended regex (-E or -r flag) */
|
||||
extendedRegex: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is a sed in-place edit command
|
||||
* Returns true only for simple sed -i 's/pattern/replacement/flags' file commands
|
||||
*/
|
||||
export function isSedInPlaceEdit(command: string): boolean {
|
||||
const info = parseSedEditCommand(command)
|
||||
return info !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a sed edit command and extract the edit information
|
||||
* Returns null if the command is not a valid sed in-place edit
|
||||
*/
|
||||
export function parseSedEditCommand(command: string): SedEditInfo | null {
|
||||
const trimmed = command.trim()
|
||||
|
||||
// Must start with sed
|
||||
const sedMatch = trimmed.match(/^\s*sed\s+/)
|
||||
if (!sedMatch) return null
|
||||
|
||||
const withoutSed = trimmed.slice(sedMatch[0].length)
|
||||
const parseResult = tryParseShellCommand(withoutSed)
|
||||
if (!parseResult.success) return null
|
||||
const tokens = parseResult.tokens
|
||||
|
||||
// Extract string tokens only
|
||||
const args: string[] = []
|
||||
for (const token of tokens) {
|
||||
if (typeof token === 'string') {
|
||||
args.push(token)
|
||||
} else if (
|
||||
typeof token === 'object' &&
|
||||
token !== null &&
|
||||
'op' in token &&
|
||||
token.op === 'glob'
|
||||
) {
|
||||
// Glob patterns are too complex for this simple parser
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Parse flags and arguments
|
||||
let hasInPlaceFlag = false
|
||||
let extendedRegex = false
|
||||
let expression: string | null = null
|
||||
let filePath: string | null = null
|
||||
|
||||
let i = 0
|
||||
while (i < args.length) {
|
||||
const arg = args[i]!
|
||||
|
||||
// Handle -i flag (with or without backup suffix)
|
||||
if (arg === '-i' || arg === '--in-place') {
|
||||
hasInPlaceFlag = true
|
||||
i++
|
||||
// On macOS, -i requires a suffix argument (even if empty string)
|
||||
// Check if next arg looks like a backup suffix (empty, or starts with dot)
|
||||
// Don't consume flags (-E, -r) or sed expressions (starting with s, y, d)
|
||||
if (i < args.length) {
|
||||
const nextArg = args[i]
|
||||
// If next arg is empty string or starts with dot, it's a backup suffix
|
||||
if (
|
||||
typeof nextArg === 'string' &&
|
||||
!nextArg.startsWith('-') &&
|
||||
(nextArg === '' || nextArg.startsWith('.'))
|
||||
) {
|
||||
i++ // Skip the backup suffix
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (arg.startsWith('-i')) {
|
||||
// -i.bak or similar (inline suffix)
|
||||
hasInPlaceFlag = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle extended regex flags
|
||||
if (arg === '-E' || arg === '-r' || arg === '--regexp-extended') {
|
||||
extendedRegex = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle -e flag with expression
|
||||
if (arg === '-e' || arg === '--expression') {
|
||||
if (i + 1 < args.length && typeof args[i + 1] === 'string') {
|
||||
// Only support single expression
|
||||
if (expression !== null) return null
|
||||
expression = args[i + 1]!
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (arg.startsWith('--expression=')) {
|
||||
if (expression !== null) return null
|
||||
expression = arg.slice('--expression='.length)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other flags we don't understand
|
||||
if (arg.startsWith('-')) {
|
||||
// Unknown flag - not safe to parse
|
||||
return null
|
||||
}
|
||||
|
||||
// Non-flag argument
|
||||
if (expression === null) {
|
||||
// First non-flag arg is the expression
|
||||
expression = arg
|
||||
} else if (filePath === null) {
|
||||
// Second non-flag arg is the file path
|
||||
filePath = arg
|
||||
} else {
|
||||
// More than one file - not supported for simple rendering
|
||||
return null
|
||||
}
|
||||
|
||||
i++
|
||||
}
|
||||
|
||||
// Must have -i flag, expression, and file path
|
||||
if (!hasInPlaceFlag || !expression || !filePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse the substitution expression: s/pattern/replacement/flags
|
||||
// Only support / as delimiter for simplicity
|
||||
const substMatch = expression.match(/^s\//)
|
||||
if (!substMatch) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rest = expression.slice(2) // Skip 's/'
|
||||
|
||||
// Find pattern and replacement by tracking escaped characters
|
||||
let pattern = ''
|
||||
let replacement = ''
|
||||
let flags = ''
|
||||
let state: 'pattern' | 'replacement' | 'flags' = 'pattern'
|
||||
let j = 0
|
||||
|
||||
while (j < rest.length) {
|
||||
const char = rest[j]!
|
||||
|
||||
if (char === '\\' && j + 1 < rest.length) {
|
||||
// Escaped character
|
||||
if (state === 'pattern') {
|
||||
pattern += char + rest[j + 1]
|
||||
} else if (state === 'replacement') {
|
||||
replacement += char + rest[j + 1]
|
||||
} else {
|
||||
flags += char + rest[j + 1]
|
||||
}
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
|
||||
if (char === '/') {
|
||||
if (state === 'pattern') {
|
||||
state = 'replacement'
|
||||
} else if (state === 'replacement') {
|
||||
state = 'flags'
|
||||
} else {
|
||||
// Extra delimiter in flags - unexpected
|
||||
return null
|
||||
}
|
||||
j++
|
||||
continue
|
||||
}
|
||||
|
||||
if (state === 'pattern') {
|
||||
pattern += char
|
||||
} else if (state === 'replacement') {
|
||||
replacement += char
|
||||
} else {
|
||||
flags += char
|
||||
}
|
||||
j++
|
||||
}
|
||||
|
||||
// Must have found all three parts (pattern, replacement delimiter, and optional flags)
|
||||
if (state !== 'flags') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate flags - only allow safe substitution flags
|
||||
const validFlags = /^[gpimIM1-9]*$/
|
||||
if (!validFlags.test(flags)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
filePath,
|
||||
pattern,
|
||||
replacement,
|
||||
flags,
|
||||
extendedRegex,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a sed substitution to file content
|
||||
* Returns the new content after applying the substitution
|
||||
*/
|
||||
export function applySedSubstitution(
|
||||
content: string,
|
||||
sedInfo: SedEditInfo,
|
||||
): string {
|
||||
// Convert sed pattern to JavaScript regex
|
||||
let regexFlags = ''
|
||||
|
||||
// Handle global flag
|
||||
if (sedInfo.flags.includes('g')) {
|
||||
regexFlags += 'g'
|
||||
}
|
||||
|
||||
// Handle case-insensitive flag (i or I in sed)
|
||||
if (sedInfo.flags.includes('i') || sedInfo.flags.includes('I')) {
|
||||
regexFlags += 'i'
|
||||
}
|
||||
|
||||
// Handle multiline flag (m or M in sed)
|
||||
if (sedInfo.flags.includes('m') || sedInfo.flags.includes('M')) {
|
||||
regexFlags += 'm'
|
||||
}
|
||||
|
||||
// Convert sed pattern to JavaScript regex pattern
|
||||
let jsPattern = sedInfo.pattern
|
||||
// Unescape \/ to /
|
||||
.replace(/\\\//g, '/')
|
||||
|
||||
// In BRE mode (no -E flag), metacharacters have opposite escaping:
|
||||
// BRE: \+ means "one or more", + is literal
|
||||
// ERE/JS: + means "one or more", \+ is literal
|
||||
// We need to convert BRE escaping to ERE for JavaScript regex
|
||||
if (!sedInfo.extendedRegex) {
|
||||
jsPattern = jsPattern
|
||||
// Step 1: Protect literal backslashes (\\) first - in both BRE and ERE, \\ is literal backslash
|
||||
.replace(/\\\\/g, BACKSLASH_PLACEHOLDER)
|
||||
// Step 2: Replace escaped metacharacters with placeholders (these should become unescaped in JS)
|
||||
.replace(/\\\+/g, PLUS_PLACEHOLDER)
|
||||
.replace(/\\\?/g, QUESTION_PLACEHOLDER)
|
||||
.replace(/\\\|/g, PIPE_PLACEHOLDER)
|
||||
.replace(/\\\(/g, LPAREN_PLACEHOLDER)
|
||||
.replace(/\\\)/g, RPAREN_PLACEHOLDER)
|
||||
// Step 3: Escape unescaped metacharacters (these are literal in BRE)
|
||||
.replace(/\+/g, '\\+')
|
||||
.replace(/\?/g, '\\?')
|
||||
.replace(/\|/g, '\\|')
|
||||
.replace(/\(/g, '\\(')
|
||||
.replace(/\)/g, '\\)')
|
||||
// Step 4: Replace placeholders with their JS equivalents
|
||||
.replace(BACKSLASH_PLACEHOLDER_RE, '\\\\')
|
||||
.replace(PLUS_PLACEHOLDER_RE, '+')
|
||||
.replace(QUESTION_PLACEHOLDER_RE, '?')
|
||||
.replace(PIPE_PLACEHOLDER_RE, '|')
|
||||
.replace(LPAREN_PLACEHOLDER_RE, '(')
|
||||
.replace(RPAREN_PLACEHOLDER_RE, ')')
|
||||
}
|
||||
|
||||
// Unescape sed-specific escapes in replacement
|
||||
// Convert \n to newline, & to $& (match), etc.
|
||||
// Use a unique placeholder with random salt to prevent injection attacks
|
||||
const salt = randomBytes(8).toString('hex')
|
||||
const ESCAPED_AMP_PLACEHOLDER = `___ESCAPED_AMPERSAND_${salt}___`
|
||||
const jsReplacement = sedInfo.replacement
|
||||
// Unescape \/ to /
|
||||
.replace(/\\\//g, '/')
|
||||
// First escape \& to a placeholder
|
||||
.replace(/\\&/g, ESCAPED_AMP_PLACEHOLDER)
|
||||
// Convert & to $& (full match) - use $$& to get literal $& in output
|
||||
.replace(/&/g, '$$&')
|
||||
// Convert placeholder back to literal &
|
||||
.replace(new RegExp(ESCAPED_AMP_PLACEHOLDER, 'g'), '&')
|
||||
|
||||
try {
|
||||
const regex = new RegExp(jsPattern, regexFlags)
|
||||
return content.replace(regex, jsReplacement)
|
||||
} catch {
|
||||
// If regex is invalid, return original content
|
||||
return content
|
||||
}
|
||||
}
|
||||
684
packages/builtin-tools/src/tools/BashTool/sedValidation.ts
Normal file
684
packages/builtin-tools/src/tools/BashTool/sedValidation.ts
Normal file
@@ -0,0 +1,684 @@
|
||||
import type { ToolPermissionContext } from 'src/Tool.js'
|
||||
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
|
||||
import { tryParseShellCommand } from 'src/utils/bash/shellQuote.js'
|
||||
import type { PermissionResult } from 'src/utils/permissions/PermissionResult.js'
|
||||
|
||||
/**
|
||||
* Helper: Validate flags against an allowlist
|
||||
* Handles both single flags and combined flags (e.g., -nE)
|
||||
* @param flags Array of flags to validate
|
||||
* @param allowedFlags Array of allowed single-character and long flags
|
||||
* @returns true if all flags are valid, false otherwise
|
||||
*/
|
||||
function validateFlagsAgainstAllowlist(
|
||||
flags: string[],
|
||||
allowedFlags: string[],
|
||||
): boolean {
|
||||
for (const flag of flags) {
|
||||
// Handle combined flags like -nE or -Er
|
||||
if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) {
|
||||
// Check each character in combined flag
|
||||
for (let i = 1; i < flag.length; i++) {
|
||||
const singleFlag = '-' + flag[i]
|
||||
if (!allowedFlags.includes(singleFlag)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single flag or long flag
|
||||
if (!allowedFlags.includes(flag)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern 1: Check if this is a line printing command with -n flag
|
||||
* Allows: sed -n 'N' | sed -n 'N,M' with optional -E, -r, -z flags
|
||||
* Allows semicolon-separated print commands like: sed -n '1p;2p;3p'
|
||||
* File arguments are ALLOWED for this pattern
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
export function isLinePrintingCommand(
|
||||
command: string,
|
||||
expressions: string[],
|
||||
): boolean {
|
||||
const sedMatch = command.match(/^\s*sed\s+/)
|
||||
if (!sedMatch) return false
|
||||
|
||||
const withoutSed = command.slice(sedMatch[0].length)
|
||||
const parseResult = tryParseShellCommand(withoutSed)
|
||||
if (!parseResult.success) return false
|
||||
const parsed = parseResult.tokens
|
||||
|
||||
// Extract all flags
|
||||
const flags: string[] = []
|
||||
for (const arg of parsed) {
|
||||
if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
|
||||
flags.push(arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate flags - only allow -n, -E, -r, -z and their long forms
|
||||
const allowedFlags = [
|
||||
'-n',
|
||||
'--quiet',
|
||||
'--silent',
|
||||
'-E',
|
||||
'--regexp-extended',
|
||||
'-r',
|
||||
'-z',
|
||||
'--zero-terminated',
|
||||
'--posix',
|
||||
]
|
||||
|
||||
if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if -n flag is present (required for Pattern 1)
|
||||
let hasNFlag = false
|
||||
for (const flag of flags) {
|
||||
if (flag === '-n' || flag === '--quiet' || flag === '--silent') {
|
||||
hasNFlag = true
|
||||
break
|
||||
}
|
||||
// Check in combined flags
|
||||
if (flag.startsWith('-') && !flag.startsWith('--') && flag.includes('n')) {
|
||||
hasNFlag = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Must have -n flag for Pattern 1
|
||||
if (!hasNFlag) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have at least one expression
|
||||
if (expressions.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// All expressions must be print commands (strict allowlist)
|
||||
// Allow semicolon-separated commands
|
||||
for (const expr of expressions) {
|
||||
const commands = expr.split(';')
|
||||
for (const cmd of commands) {
|
||||
if (!isPrintCommand(cmd.trim())) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check if a single command is a valid print command
|
||||
* STRICT ALLOWLIST - only these exact forms are allowed:
|
||||
* - p (print all)
|
||||
* - Np (print line N, where N is digits)
|
||||
* - N,Mp (print lines N through M)
|
||||
* Anything else (including w, W, e, E commands) is rejected.
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
export function isPrintCommand(cmd: string): boolean {
|
||||
if (!cmd) return false
|
||||
// Single strict regex that only matches allowed print commands
|
||||
// ^(?:\d+|\d+,\d+)?p$ matches: p, 1p, 123p, 1,5p, 10,200p
|
||||
return /^(?:\d+|\d+,\d+)?p$/.test(cmd)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pattern 2: Check if this is a substitution command
|
||||
* Allows: sed 's/pattern/replacement/flags' where flags are only: g, p, i, I, m, M, 1-9
|
||||
* When allowFileWrites is true, allows -i flag and file arguments for in-place editing
|
||||
* When allowFileWrites is false (default), requires stdout-only (no file arguments, no -i flag)
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
function isSubstitutionCommand(
|
||||
command: string,
|
||||
expressions: string[],
|
||||
hasFileArguments: boolean,
|
||||
options?: { allowFileWrites?: boolean },
|
||||
): boolean {
|
||||
const allowFileWrites = options?.allowFileWrites ?? false
|
||||
|
||||
// When not allowing file writes, must NOT have file arguments
|
||||
if (!allowFileWrites && hasFileArguments) {
|
||||
return false
|
||||
}
|
||||
|
||||
const sedMatch = command.match(/^\s*sed\s+/)
|
||||
if (!sedMatch) return false
|
||||
|
||||
const withoutSed = command.slice(sedMatch[0].length)
|
||||
const parseResult = tryParseShellCommand(withoutSed)
|
||||
if (!parseResult.success) return false
|
||||
const parsed = parseResult.tokens
|
||||
|
||||
// Extract all flags
|
||||
const flags: string[] = []
|
||||
for (const arg of parsed) {
|
||||
if (typeof arg === 'string' && arg.startsWith('-') && arg !== '--') {
|
||||
flags.push(arg)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate flags based on mode
|
||||
// Base allowed flags for both modes
|
||||
const allowedFlags = ['-E', '--regexp-extended', '-r', '--posix']
|
||||
|
||||
// When allowing file writes, also permit -i and --in-place
|
||||
if (allowFileWrites) {
|
||||
allowedFlags.push('-i', '--in-place')
|
||||
}
|
||||
|
||||
if (!validateFlagsAgainstAllowlist(flags, allowedFlags)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Must have exactly one expression
|
||||
if (expressions.length !== 1) {
|
||||
return false
|
||||
}
|
||||
|
||||
const expr = expressions[0]!.trim()
|
||||
|
||||
// STRICT ALLOWLIST: Must be exactly a substitution command starting with 's'
|
||||
// This rejects standalone commands like 'e', 'w file', etc.
|
||||
if (!expr.startsWith('s')) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Parse substitution: s/pattern/replacement/flags
|
||||
// Only allow / as delimiter (strict)
|
||||
const substitutionMatch = expr.match(/^s\/(.*?)$/)
|
||||
if (!substitutionMatch) {
|
||||
return false
|
||||
}
|
||||
|
||||
const rest = substitutionMatch[1]!
|
||||
|
||||
// Find the positions of / delimiters
|
||||
let delimiterCount = 0
|
||||
let lastDelimiterPos = -1
|
||||
let i = 0
|
||||
while (i < rest.length) {
|
||||
if (rest[i] === '\\') {
|
||||
// Skip escaped character
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if (rest[i] === '/') {
|
||||
delimiterCount++
|
||||
lastDelimiterPos = i
|
||||
}
|
||||
i++
|
||||
}
|
||||
|
||||
// Must have found exactly 2 delimiters (pattern and replacement)
|
||||
if (delimiterCount !== 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Extract flags (everything after the last delimiter)
|
||||
const exprFlags = rest.slice(lastDelimiterPos + 1)
|
||||
|
||||
// Validate flags: only allow g, p, i, I, m, M, and optionally ONE digit 1-9
|
||||
const allowedFlagChars = /^[gpimIM]*[1-9]?[gpimIM]*$/
|
||||
if (!allowedFlagChars.test(exprFlags)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a sed command is allowed by the allowlist.
|
||||
* The allowlist patterns themselves are strict enough to reject dangerous operations.
|
||||
* @param command The sed command to check
|
||||
* @param options.allowFileWrites When true, allows -i flag and file arguments for substitution commands
|
||||
* @returns true if the command is allowed (matches allowlist and passes denylist check), false otherwise
|
||||
*/
|
||||
export function sedCommandIsAllowedByAllowlist(
|
||||
command: string,
|
||||
options?: { allowFileWrites?: boolean },
|
||||
): boolean {
|
||||
const allowFileWrites = options?.allowFileWrites ?? false
|
||||
|
||||
// Extract sed expressions (content inside quotes where actual sed commands live)
|
||||
let expressions: string[]
|
||||
try {
|
||||
expressions = extractSedExpressions(command)
|
||||
} catch (_error) {
|
||||
// If parsing failed, treat as not allowed
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if sed command has file arguments
|
||||
const hasFileArguments = hasFileArgs(command)
|
||||
|
||||
// Check if command matches allowlist patterns
|
||||
let isPattern1 = false
|
||||
let isPattern2 = false
|
||||
|
||||
if (allowFileWrites) {
|
||||
// When allowing file writes, only check substitution commands (Pattern 2 variant)
|
||||
// Pattern 1 (line printing) doesn't need file writes
|
||||
isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments, {
|
||||
allowFileWrites: true,
|
||||
})
|
||||
} else {
|
||||
// Standard read-only mode: check both patterns
|
||||
isPattern1 = isLinePrintingCommand(command, expressions)
|
||||
isPattern2 = isSubstitutionCommand(command, expressions, hasFileArguments)
|
||||
}
|
||||
|
||||
if (!isPattern1 && !isPattern2) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Pattern 2 does not allow semicolons (command separators)
|
||||
// Pattern 1 allows semicolons for separating print commands
|
||||
for (const expr of expressions) {
|
||||
if (isPattern2 && expr.includes(';')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Defense-in-depth: Even if allowlist matches, check denylist
|
||||
for (const expr of expressions) {
|
||||
if (containsDangerousOperations(expr)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sed command has file arguments (not just stdin)
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
export function hasFileArgs(command: string): boolean {
|
||||
const sedMatch = command.match(/^\s*sed\s+/)
|
||||
if (!sedMatch) return false
|
||||
|
||||
const withoutSed = command.slice(sedMatch[0].length)
|
||||
const parseResult = tryParseShellCommand(withoutSed)
|
||||
if (!parseResult.success) return true
|
||||
const parsed = parseResult.tokens
|
||||
|
||||
try {
|
||||
let argCount = 0
|
||||
let hasEFlag = false
|
||||
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const arg = parsed[i]
|
||||
|
||||
// Handle both string arguments and glob patterns (like *.log)
|
||||
if (typeof arg !== 'string' && typeof arg !== 'object') continue
|
||||
|
||||
// If it's a glob pattern, it counts as a file argument
|
||||
if (
|
||||
typeof arg === 'object' &&
|
||||
arg !== null &&
|
||||
'op' in arg &&
|
||||
arg.op === 'glob'
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Skip non-string arguments that aren't glob patterns
|
||||
if (typeof arg !== 'string') continue
|
||||
|
||||
// Handle -e flag followed by expression
|
||||
if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
|
||||
hasEFlag = true
|
||||
i++ // Skip the next argument since it's the expression
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle --expression=value format
|
||||
if (arg.startsWith('--expression=')) {
|
||||
hasEFlag = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle -e=value format (non-standard but defense in depth)
|
||||
if (arg.startsWith('-e=')) {
|
||||
hasEFlag = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other flags
|
||||
if (arg.startsWith('-')) continue
|
||||
|
||||
argCount++
|
||||
|
||||
// If we used -e flags, ALL non-flag arguments are file arguments
|
||||
if (hasEFlag) {
|
||||
return true
|
||||
}
|
||||
|
||||
// If we didn't use -e flags, the first non-flag argument is the sed expression,
|
||||
// so we need more than 1 non-flag argument to have file arguments
|
||||
if (argCount > 1) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (_error) {
|
||||
return true // Assume dangerous if parsing fails
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract sed expressions from command, ignoring flags and filenames
|
||||
* @param command Full sed command
|
||||
* @returns Array of sed expressions to check for dangerous operations
|
||||
* @throws Error if parsing fails
|
||||
* @internal Exported for testing
|
||||
*/
|
||||
export function extractSedExpressions(command: string): string[] {
|
||||
const expressions: string[] = []
|
||||
|
||||
// Calculate withoutSed by trimming off the first N characters (removing 'sed ')
|
||||
const sedMatch = command.match(/^\s*sed\s+/)
|
||||
if (!sedMatch) return expressions
|
||||
|
||||
const withoutSed = command.slice(sedMatch[0].length)
|
||||
|
||||
// Reject dangerous flag combinations like -ew, -eW, -ee, -we (combined -e/-w with dangerous commands)
|
||||
if (/-e[wWe]/.test(withoutSed) || /-w[eE]/.test(withoutSed)) {
|
||||
throw new Error('Dangerous flag combination detected')
|
||||
}
|
||||
|
||||
// Use shell-quote to parse the arguments properly
|
||||
const parseResult = tryParseShellCommand(withoutSed)
|
||||
if (!parseResult.success) {
|
||||
// Malformed shell syntax - throw error to be caught by caller
|
||||
throw new Error(`Malformed shell syntax: ${(parseResult as { success: false; error: string }).error}`)
|
||||
}
|
||||
const parsed = parseResult.tokens
|
||||
try {
|
||||
let foundEFlag = false
|
||||
let foundExpression = false
|
||||
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const arg = parsed[i]
|
||||
|
||||
// Skip non-string arguments (like control operators)
|
||||
if (typeof arg !== 'string') continue
|
||||
|
||||
// Handle -e flag followed by expression
|
||||
if ((arg === '-e' || arg === '--expression') && i + 1 < parsed.length) {
|
||||
foundEFlag = true
|
||||
const nextArg = parsed[i + 1]
|
||||
if (typeof nextArg === 'string') {
|
||||
expressions.push(nextArg)
|
||||
i++ // Skip the next argument since we consumed it
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle --expression=value format
|
||||
if (arg.startsWith('--expression=')) {
|
||||
foundEFlag = true
|
||||
expressions.push(arg.slice('--expression='.length))
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle -e=value format (non-standard but defense in depth)
|
||||
if (arg.startsWith('-e=')) {
|
||||
foundEFlag = true
|
||||
expressions.push(arg.slice('-e='.length))
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip other flags
|
||||
if (arg.startsWith('-')) continue
|
||||
|
||||
// If we haven't found any -e flags, the first non-flag argument is the sed expression
|
||||
if (!foundEFlag && !foundExpression) {
|
||||
expressions.push(arg)
|
||||
foundExpression = true
|
||||
continue
|
||||
}
|
||||
|
||||
// If we've already found -e flags or a standalone expression,
|
||||
// remaining non-flag arguments are filenames
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
// If shell-quote parsing fails, treat the sed command as unsafe
|
||||
throw new Error(
|
||||
`Failed to parse sed command: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
)
|
||||
}
|
||||
|
||||
return expressions
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sed expression contains dangerous operations (denylist)
|
||||
* @param expression Single sed expression (without quotes)
|
||||
* @returns true if dangerous, false if safe
|
||||
*/
|
||||
function containsDangerousOperations(expression: string): boolean {
|
||||
const cmd = expression.trim()
|
||||
if (!cmd) return false
|
||||
|
||||
// CONSERVATIVE REJECTIONS: Broadly reject patterns that could be dangerous
|
||||
// When in doubt, treat as unsafe
|
||||
|
||||
// Reject non-ASCII characters (Unicode homoglyphs, combining chars, etc.)
|
||||
// Examples: w (fullwidth), ᴡ (small capital), w̃ (combining tilde)
|
||||
// Check for characters outside ASCII range (0x01-0x7F, excluding null byte)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
if (/[^\x01-\x7F]/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject curly braces (blocks) - too complex to parse
|
||||
if (cmd.includes('{') || cmd.includes('}')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject newlines - multi-line commands are too complex
|
||||
if (cmd.includes('\n')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject comments (# not immediately after s command)
|
||||
// Comments look like: #comment or start with #
|
||||
// Delimiter looks like: s#pattern#replacement#
|
||||
const hashIndex = cmd.indexOf('#')
|
||||
if (hashIndex !== -1 && !(hashIndex > 0 && cmd[hashIndex - 1] === 's')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject negation operator
|
||||
// Negation can appear: at start (!/pattern/), after address (/pattern/!, 1,10!, $!)
|
||||
// Delimiter looks like: s!pattern!replacement! (has 's' before it)
|
||||
if (/^!/.test(cmd) || /[/\d$]!/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject tilde in GNU step address format (digit~digit, ,~digit, or $~digit)
|
||||
// Allow whitespace around tilde
|
||||
if (/\d\s*~\s*\d|,\s*~\s*\d|\$\s*~\s*\d/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject comma at start (bare comma is shorthand for 1,$ address range)
|
||||
if (/^,/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject comma followed by +/- (GNU offset addresses)
|
||||
if (/,\s*[+-]/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject backslash tricks:
|
||||
// 1. s\ (substitution with backslash delimiter)
|
||||
// 2. \X where X could be an alternate delimiter (|, #, %, etc.) - not regex escapes
|
||||
if (/s\\/.test(cmd) || /\\[|#%@]/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject escaped slashes followed by w/W (patterns like /\/path\/to\/file/w)
|
||||
if (/\\\/.*[wW]/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject malformed/suspicious patterns we don't understand
|
||||
// If there's a slash followed by non-slash chars, then whitespace, then dangerous commands
|
||||
// Examples: /pattern w file, /pattern e cmd, /foo X;w file
|
||||
if (/\/[^/]*\s+[wWeE]/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Reject malformed substitution commands that don't follow normal pattern
|
||||
// Examples: s/foobareoutput.txt (missing delimiters), s/foo/bar//w (extra delimiter)
|
||||
if (/^s\//.test(cmd) && !/^s\/[^/]*\/[^/]*\/[^/]*$/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// PARANOID: Reject any command starting with 's' that ends with dangerous chars (w, W, e, E)
|
||||
// and doesn't match our known safe substitution pattern. This catches malformed s commands
|
||||
// with non-slash delimiters that might be trying to use dangerous flags.
|
||||
if (/^s./.test(cmd) && /[wWeE]$/.test(cmd)) {
|
||||
// Check if it's a properly formed substitution (any delimiter, not just /)
|
||||
const properSubst = /^s([^\\\n]).*?\1.*?\1[^wWeE]*$/.test(cmd)
|
||||
if (!properSubst) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dangerous write commands
|
||||
// Patterns: [address]w filename, [address]W filename, /pattern/w filename, /pattern/W filename
|
||||
// Simplified to avoid exponential backtracking (CodeQL issue)
|
||||
// Check for w/W in contexts where it would be a command (with optional whitespace)
|
||||
if (
|
||||
/^[wW]\s*\S+/.test(cmd) || // At start: w file
|
||||
/^\d+\s*[wW]\s*\S+/.test(cmd) || // After line number: 1w file or 1 w file
|
||||
/^\$\s*[wW]\s*\S+/.test(cmd) || // After $: $w file or $ w file
|
||||
/^\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) || // After pattern: /pattern/w file
|
||||
/^\d+,\d+\s*[wW]\s*\S+/.test(cmd) || // After range: 1,10w file
|
||||
/^\d+,\$\s*[wW]\s*\S+/.test(cmd) || // After range: 1,$w file
|
||||
/^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*[wW]\s*\S+/.test(cmd) // After pattern range: /s/,/e/w file
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for dangerous execute commands
|
||||
// Patterns: [address]e [command], /pattern/e [command], or commands starting with e
|
||||
// Simplified to avoid exponential backtracking (CodeQL issue)
|
||||
// Check for e in contexts where it would be a command (with optional whitespace)
|
||||
if (
|
||||
/^e/.test(cmd) || // At start: e cmd
|
||||
/^\d+\s*e/.test(cmd) || // After line number: 1e or 1 e
|
||||
/^\$\s*e/.test(cmd) || // After $: $e or $ e
|
||||
/^\/[^/]*\/[IMim]*\s*e/.test(cmd) || // After pattern: /pattern/e
|
||||
/^\d+,\d+\s*e/.test(cmd) || // After range: 1,10e
|
||||
/^\d+,\$\s*e/.test(cmd) || // After range: 1,$e
|
||||
/^\/[^/]*\/[IMim]*,\/[^/]*\/[IMim]*\s*e/.test(cmd) // After pattern range: /s/,/e/e
|
||||
) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for substitution commands with dangerous flags
|
||||
// Pattern: s<delim>pattern<delim>replacement<delim>flags where flags contain w or e
|
||||
// Per POSIX, sed allows any character except backslash and newline as delimiter
|
||||
const substitutionMatch = cmd.match(/s([^\\\n]).*?\1.*?\1(.*?)$/)
|
||||
if (substitutionMatch) {
|
||||
const flags = substitutionMatch[2] || ''
|
||||
|
||||
// Check for write flag: s/old/new/w filename or s/old/new/gw filename
|
||||
if (flags.includes('w') || flags.includes('W')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for execute flag: s/old/new/e or s/old/new/ge
|
||||
if (flags.includes('e') || flags.includes('E')) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for y (transliterate) command followed by dangerous operations
|
||||
// Pattern: y<delim>source<delim>dest<delim> followed by anything
|
||||
// The y command uses same delimiter syntax as s command
|
||||
// PARANOID: Reject any y command that has w/W/e/E anywhere after the delimiters
|
||||
const yCommandMatch = cmd.match(/y([^\\\n])/)
|
||||
if (yCommandMatch) {
|
||||
// If we see a y command, check if there's any w, W, e, or E in the entire command
|
||||
// This is paranoid but safe - y commands are rare and w/e after y is suspicious
|
||||
if (/[wWeE]/.test(cmd)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Cross-cutting validation step for sed commands.
|
||||
*
|
||||
* This is a constraint check that blocks dangerous sed operations regardless of mode.
|
||||
* It returns 'passthrough' for non-sed commands or safe sed commands,
|
||||
* and 'ask' for dangerous sed operations (w/W/e/E commands).
|
||||
*
|
||||
* @param input - Object containing the command string
|
||||
* @param toolPermissionContext - Context containing mode and permissions
|
||||
* @returns
|
||||
* - 'ask' if any sed command contains dangerous operations
|
||||
* - 'passthrough' if no sed commands or all are safe
|
||||
*/
|
||||
export function checkSedConstraints(
|
||||
input: { command: string },
|
||||
toolPermissionContext: ToolPermissionContext,
|
||||
): PermissionResult {
|
||||
const commands = splitCommand_DEPRECATED(input.command)
|
||||
|
||||
for (const cmd of commands) {
|
||||
// Skip non-sed commands
|
||||
const trimmed = cmd.trim()
|
||||
const baseCmd = trimmed.split(/\s+/)[0]
|
||||
if (baseCmd !== 'sed') {
|
||||
continue
|
||||
}
|
||||
|
||||
// In acceptEdits mode, allow file writes (-i flag) but still block dangerous operations
|
||||
const allowFileWrites = toolPermissionContext.mode === 'acceptEdits'
|
||||
|
||||
const isAllowed = sedCommandIsAllowedByAllowlist(trimmed, {
|
||||
allowFileWrites,
|
||||
})
|
||||
|
||||
if (!isAllowed) {
|
||||
return {
|
||||
behavior: 'ask',
|
||||
message:
|
||||
'sed command requires approval (contains potentially dangerous operations)',
|
||||
decisionReason: {
|
||||
type: 'other',
|
||||
reason:
|
||||
'sed command contains operations that require explicit approval (e.g., write commands, execute commands)',
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// No dangerous sed commands found (or no sed commands at all)
|
||||
return {
|
||||
behavior: 'passthrough',
|
||||
message: 'No dangerous sed operations detected',
|
||||
}
|
||||
}
|
||||
153
packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts
Normal file
153
packages/builtin-tools/src/tools/BashTool/shouldUseSandbox.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'
|
||||
import { splitCommand_DEPRECATED } from 'src/utils/bash/commands.js'
|
||||
import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'
|
||||
import { getSettings_DEPRECATED } from 'src/utils/settings/settings.js'
|
||||
import {
|
||||
BINARY_HIJACK_VARS,
|
||||
bashPermissionRule,
|
||||
matchWildcardPattern,
|
||||
stripAllLeadingEnvVars,
|
||||
stripSafeWrappers,
|
||||
} from './bashPermissions.js'
|
||||
|
||||
type SandboxInput = {
|
||||
command?: string
|
||||
dangerouslyDisableSandbox?: boolean
|
||||
}
|
||||
|
||||
// NOTE: excludedCommands is a user-facing convenience feature, not a security boundary.
|
||||
// It is not a security bug to be able to bypass excludedCommands — the sandbox permission
|
||||
// system (which prompts users) is the actual security control.
|
||||
function containsExcludedCommand(command: string): boolean {
|
||||
// Check dynamic config for disabled commands and substrings (only for ants)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
|
||||
commands: string[]
|
||||
substrings: string[]
|
||||
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
|
||||
|
||||
// Check if command contains any disabled substrings
|
||||
for (const substring of disabledCommands.substrings) {
|
||||
if (command.includes(substring)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if command starts with any disabled commands
|
||||
try {
|
||||
const commandParts = splitCommand_DEPRECATED(command)
|
||||
for (const part of commandParts) {
|
||||
const baseCommand = part.trim().split(' ')[0]
|
||||
if (baseCommand && disabledCommands.commands.includes(baseCommand)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the command (e.g., malformed bash syntax),
|
||||
// treat it as not excluded to allow other validation checks to handle it
|
||||
// This prevents crashes when rendering tool use messages
|
||||
}
|
||||
}
|
||||
|
||||
// Check user-configured excluded commands from settings
|
||||
const settings = getSettings_DEPRECATED()
|
||||
const userExcludedCommands = settings.sandbox?.excludedCommands ?? []
|
||||
|
||||
if (userExcludedCommands.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Split compound commands (e.g. "docker ps && curl evil.com") into individual
|
||||
// subcommands and check each one against excluded patterns. This prevents a
|
||||
// compound command from escaping the sandbox just because its first subcommand
|
||||
// matches an excluded pattern.
|
||||
let subcommands: string[]
|
||||
try {
|
||||
subcommands = splitCommand_DEPRECATED(command)
|
||||
} catch {
|
||||
subcommands = [command]
|
||||
}
|
||||
|
||||
for (const subcommand of subcommands) {
|
||||
const trimmed = subcommand.trim()
|
||||
// Also try matching with env var prefixes and wrapper commands stripped, so
|
||||
// that `FOO=bar bazel ...` and `timeout 30 bazel ...` match `bazel:*`. Not a
|
||||
// security boundary (see NOTE at top); the &&-split above already lets
|
||||
// `export FOO=bar && bazel ...` match. BINARY_HIJACK_VARS kept as a heuristic.
|
||||
//
|
||||
// We iteratively apply both stripping operations until no new candidates are
|
||||
// produced (fixed-point), matching the approach in filterRulesByContentsMatchingInput.
|
||||
// This handles interleaved patterns like `timeout 300 FOO=bar bazel run`
|
||||
// where single-pass composition would fail.
|
||||
const candidates = [trimmed]
|
||||
const seen = new Set(candidates)
|
||||
let startIdx = 0
|
||||
while (startIdx < candidates.length) {
|
||||
const endIdx = candidates.length
|
||||
for (let i = startIdx; i < endIdx; i++) {
|
||||
const cmd = candidates[i]!
|
||||
const envStripped = stripAllLeadingEnvVars(cmd, BINARY_HIJACK_VARS)
|
||||
if (!seen.has(envStripped)) {
|
||||
candidates.push(envStripped)
|
||||
seen.add(envStripped)
|
||||
}
|
||||
const wrapperStripped = stripSafeWrappers(cmd)
|
||||
if (!seen.has(wrapperStripped)) {
|
||||
candidates.push(wrapperStripped)
|
||||
seen.add(wrapperStripped)
|
||||
}
|
||||
}
|
||||
startIdx = endIdx
|
||||
}
|
||||
|
||||
for (const pattern of userExcludedCommands) {
|
||||
const rule = bashPermissionRule(pattern)
|
||||
for (const cand of candidates) {
|
||||
switch (rule.type) {
|
||||
case 'prefix':
|
||||
if (cand === rule.prefix || cand.startsWith(rule.prefix + ' ')) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
case 'exact':
|
||||
if (cand === rule.command) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
case 'wildcard':
|
||||
if (matchWildcardPattern(rule.pattern, cand)) {
|
||||
return true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function shouldUseSandbox(input: Partial<SandboxInput>): boolean {
|
||||
if (!SandboxManager.isSandboxingEnabled()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't sandbox if explicitly overridden AND unsandboxed commands are allowed by policy
|
||||
if (
|
||||
input.dangerouslyDisableSandbox &&
|
||||
SandboxManager.areUnsandboxedCommandsAllowed()
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!input.command) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Don't sandbox if the command contains user-configured excluded commands
|
||||
if (containsExcludedCommand(input.command)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
2
packages/builtin-tools/src/tools/BashTool/src/Tool.ts
Normal file
2
packages/builtin-tools/src/tools/BashTool/src/Tool.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type ToolPermissionContext = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getOriginalCwd = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type CanUseToolFn = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getFeatureValue_CACHED_MAY_BE_STALE = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type logEvent = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type AppState = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type setCwd = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type getCwd = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type pathInAllowedWorkingPath = any;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Auto-generated type stub — replace with real implementation
|
||||
export type removeSandboxViolationTags = any;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user