docs: Heicode 愿景范式、瀑布/敏捷子 Agnet 角色与规模

- 新增 docs/vision-heicode-full-stack-agentic-dev.md(完整范式与 W1-W9 / A1-A8 角色)
- 新增根 README.md 与 .gitignore(排除 node_modules、target 等)

Made-with: Cursor
This commit is contained in:
gongzhiyong
2026-04-30 00:33:20 +08:00
commit 0c84dde85b
1158 changed files with 257958 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
# Dependencies
node_modules/
# Build / cache
**/target/
dist/
build/
.turbo/
.next/
*.tsbuildinfo
# Environment
.env
.env.*
!.env.example
# OS / IDE
.DS_Store
.idea/
.vscode/
*.swp
*.swo
# Logs
*.log
logs/
# Go
*.exe
*.test
*.out
+32
View File
@@ -0,0 +1,32 @@
# Heicode
Heicode 是一个**面向团队的智能体驱动软件交付**工作区:把 **改造后的大模型网关(New API)**、**终端与桌面上的智能体编程客户端(cc-haha)**,以及 **Agnet 平台上的子智能体团队** 串成一条从想法到上线、再到运维迭代的闭环。
## 这个仓库里有什么
| 目录 | 作用 |
|------|------|
| `new-api/` | 基于 New API 生态的 **网关与运营能力**(模型渠道、计费、鉴权、多协议中继等),经深度改造后作为用户登录后看到的 **后台与 API 能力层**。 |
| `cc-haha/` | **CLI / 本地服务 / Desktop(Tauri)** 相关代码,提供类 **Claude Code** 的编程与协作体验,并与 New API 对齐登录与接口。 |
| `_all-in-one-upload/` | 历史或打包上传相关目录(按需维护)。 |
## 产品在解决什么问题
- **自动化开发范式**:不止「写代码」,而是覆盖需求、实现、测试、**多云部署**、运维与迭代的 **标准化团队模式**(瀑布 / 敏捷模板 + 子智能体规模可配置)。
- **平台整合**:用户从官网进入 → 使用改造后的 **New API** 控制台 → 下载 **CLI** 与 **Desktop** → 与 **Agnet** 上已部署的 **子智能体团队** 协同;子智能体可具备 Git 与云资源(GCP / AWS / Azure)的**策略化**自动化能力。
- **可审计交付**:多智能体协作记录、产品文档、开发日志等与版本和发布关联。
## 详细愿景与范式说明
完整的产品叙事、三问回答、架构分层、用户旅程、**瀑布(5~9)与敏捷(3~8)子 Agnet 角色清单**、风险与路线图见:
**[docs/vision-heicode-full-stack-agentic-dev.md](./docs/vision-heicode-full-stack-agentic-dev.md)**
## 相关外部参考(概念)
- [oh-my-claudecode](https://ohmyclaudecode.com/) — 高效使用 Claude Code 类工具的实践与脚手架方向。
- Agnet 平台文档(部署与编排由平台侧提供)— 集成时以平台当前 API 与权限模型为准。
## 许可证
各子项目可能沿用不同开源许可证(例如 `new-api` 侧常见为 AGPLv3),请以各子目录内 `LICENSE` 为准。
@@ -0,0 +1,87 @@
# Claude Code IM Adapters
当前目录只放 IM Adapter 运行时代码。
用户文档已经迁移到 `docs/`,并且以 Desktop Webapp 配置流程为准:
- `docs/im/index.md`
- `docs/im/telegram.md`
- `docs/im/feishu.md`
## 当前方案摘要
当前真实链路是:
```text
Desktop Webapp Settings
-> /api/adapters
-> ~/.claude/adapters.json
-> adapters/<platform>/index.ts
-> /api/sessions + /ws/:sessionId
-> Claude Code session
```
注意两点:
- IM 配置和配对都在 Desktop Webapp 的 `Settings -> IM 接入`
- Webapp 不会自动启动 Adapter 进程,仍需手动运行 `bun run telegram` 或 `bun run feishu`
## 快速启动
```bash
cd adapters
bun install
bun run telegram
# 或
bun run feishu
```
## 开发
### 运行测试
```bash
cd adapters
bun test
bun test common/
bun test telegram/
bun test feishu/
```
### 目录结构
```text
adapters/
├── common/
│ └── attachment/ # 跨平台附件工具(types / limits / store / image-watcher)
├── telegram/
│ └── media.ts # TelegramMediaService(grammy Bot API 封装)
├── feishu/
│ ├── media.ts # FeishuMediaService(@larksuiteoapi/node-sdk 封装)
│ └── extract-payload.ts # 入站 im.message.receive_v1 事件解析
├── package.json
├── tsconfig.json
└── README.md
```
## 附件收发
两个 Adapter 都支持双向图片/文件,和 Desktop 端走同一套 `AttachmentRef` 协议透传给主进程。
**入站(用户 → Claude):**
- 飞书: 图片(jpg/png/gif/webp/heic)、文档(doc/xls/ppt/pdf 等)、post 富文本里的 img/file 元素
- Telegram: photo、document、video、audio、voice
下载落地到 `~/.claude/im-downloads/{platform}/{sessionId}/`,24 小时后自动 GC(`.part` 孤文件 10 分钟超时)。大小限制:单张图 ≤10 MB、单个文件 ≤30 MB,超限直接拒收并在 IM 里提示。
**出站(Claude → 用户):**
Agent 流式文本里的 markdown 图片引用 `![alt](path|url|data:)` 会被 `ImageBlockWatcher` 识别、上传到 IM 平台,作为独立图片消息发出:
- 飞书: `im.message.create(msg_type='image')` 单发(card 内嵌是后续优化)
- Telegram: `bot.api.sendPhoto(InputFile)` 单发
非图片类出站(Agent 产的 pdf/zip 等)暂不支持。
设计细节: `docs/superpowers/specs/2026-04-11-im-attachment-support-design.md`。
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,76 @@
{
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri-schema-v2.json",
"productName": "HeiCode",
"version": "0.1.0",
"identifier": "com.heicode.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "bun run build:sidecars && bun run dev",
"beforeBuildCommand": "bun run build && bun run build:sidecars"
},
"app": {
"windows": [
{
"title": "HeiCode",
"width": 1440,
"height": 960,
"minWidth": 960,
"minHeight": 640,
"decorations": true,
"transparent": false,
"acceptFirstMouse": true
}
],
"security": {
"dangerousDisableAssetCspModification": ["style-src"],
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: https://asset.localhost; font-src 'self' data:; connect-src 'self' ws://127.0.0.1:* http://127.0.0.1:* ws://localhost:* http://localhost:*; media-src 'self' blob:"
}
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDlCOUIwRDExQTc5RTFGMzYKUldRMkg1Nm5FUTJibTJ2cGlHY0pkL0dGemxXMUlzc01pVTVMM1U3WGpmWUtrUC8wK2ErSXhLKzEK",
"endpoints": [],
"windows": {
"installMode": "passive"
}
}
},
"bundle": {
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"windows": {
"nsis": {
"installerHooks": "windows-installer-hooks.nsh"
}
},
"externalBin": [
"binaries/claude-sidecar"
],
"resources": [],
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"macOS": {
"dmg": {
"appPosition": {
"x": 180,
"y": 170
},
"applicationFolderPosition": {
"x": 480,
"y": 170
},
"windowSize": {
"width": 660,
"height": 400
}
}
}
}
}
@@ -0,0 +1,22 @@
import { api } from './client'
import type { SkillMeta, SkillDetail } from '../types/skill'
export const skillsApi = {
list: (cwd?: string) => {
const query = cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''
return api.get<{ skills: SkillMeta[] }>(`/api/skills${query}`, { timeout: 120_000 })
},
detail: (source: string, name: string, cwd?: string) => {
const query = new URLSearchParams({
source,
name,
})
if (cwd) query.set('cwd', cwd)
return api.get<{ detail: SkillDetail }>(
`/api/skills/detail?${query.toString()}`,
{ timeout: 120_000 },
)
},
}
@@ -0,0 +1,92 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('./client', () => ({
getBaseUrl: () => 'http://127.0.0.1:3456',
}))
import { wsManager } from './websocket'
type SocketHandler = (() => void) | ((event: { data: string }) => void)
class FakeWebSocket {
static readonly CONNECTING = 0
static readonly OPEN = 1
static readonly CLOSING = 2
static readonly CLOSED = 3
static instances: FakeWebSocket[] = []
readonly url: string
readyState = FakeWebSocket.CONNECTING
onopen: SocketHandler | null = null
onmessage: SocketHandler | null = null
onclose: SocketHandler | null = null
onerror: SocketHandler | null = null
sent: string[] = []
constructor(url: string) {
this.url = url
FakeWebSocket.instances.push(this)
}
send(data: string) {
this.sent.push(data)
}
close() {
this.readyState = FakeWebSocket.CLOSED
;(this.onclose as (() => void) | null)?.()
}
open() {
this.readyState = FakeWebSocket.OPEN
;(this.onopen as (() => void) | null)?.()
}
fail() {
this.readyState = FakeWebSocket.CLOSED
;(this.onclose as (() => void) | null)?.()
}
}
describe('wsManager reconnect buffering', () => {
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
vi.useFakeTimers()
FakeWebSocket.instances = []
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket
wsManager.disconnectAll()
})
afterEach(() => {
wsManager.disconnectAll()
globalThis.WebSocket = originalWebSocket
vi.useRealTimers()
})
it('replays queued messages after an unexpected reconnect', async () => {
wsManager.connect('session-reconnect')
const firstSocket = FakeWebSocket.instances[0]
expect(firstSocket?.url).toContain('/ws/session-reconnect')
firstSocket!.open()
wsManager.send('session-reconnect', { type: 'user_message', content: 'first' })
expect(firstSocket!.sent).toEqual([
JSON.stringify({ type: 'user_message', content: 'first' }),
])
firstSocket!.fail()
wsManager.send('session-reconnect', { type: 'user_message', content: 'queued while offline' })
await vi.advanceTimersByTimeAsync(1000)
const secondSocket = FakeWebSocket.instances[1]
expect(secondSocket).toBeDefined()
secondSocket!.open()
expect(secondSocket!.sent).toEqual([
JSON.stringify({ type: 'user_message', content: 'queued while offline' }),
])
})
})
@@ -0,0 +1,106 @@
import { useEffect } from 'react'
import { useTranslation } from '../../i18n'
import { MarkdownRenderer } from '../markdown/MarkdownRenderer'
import { isTauriRuntime } from '../../lib/desktopRuntime'
import { useUpdateStore } from '../../stores/updateStore'
import { formatBytes } from '../../lib/formatBytes'
export function UpdateChecker() {
const t = useTranslation()
const status = useUpdateStore((s) => s.status)
const availableVersion = useUpdateStore((s) => s.availableVersion)
const releaseNotes = useUpdateStore((s) => s.releaseNotes)
const progressPercent = useUpdateStore((s) => s.progressPercent)
const downloadedBytes = useUpdateStore((s) => s.downloadedBytes)
const totalBytes = useUpdateStore((s) => s.totalBytes)
const error = useUpdateStore((s) => s.error)
const shouldPrompt = useUpdateStore((s) => s.shouldPrompt)
const initialize = useUpdateStore((s) => s.initialize)
const installUpdate = useUpdateStore((s) => s.installUpdate)
const dismissPrompt = useUpdateStore((s) => s.dismissPrompt)
useEffect(() => {
void initialize()
}, [initialize])
if (!isTauriRuntime()) return null
const showPopup =
shouldPrompt && !!availableVersion && ['available', 'downloading', 'restarting'].includes(status)
if (!showPopup) return null
const hasKnownProgress = typeof totalBytes === 'number' && totalBytes > 0
const downloadedText = formatBytes(downloadedBytes)
const statusText =
status === 'restarting'
? t('update.restarting')
: status === 'downloading'
? hasKnownProgress
? t('update.downloading')
: t('update.progressBytes', { downloaded: downloadedText })
: null
return (
<div className="fixed top-4 right-4 z-[200] max-w-sm">
<div className="bg-[var(--color-surface-container-low)] border border-[var(--color-border)] rounded-[var(--radius-lg)] shadow-[var(--shadow-dropdown)] p-4">
<p className="text-sm font-medium text-[var(--color-text-primary)]">
{t('update.available', { version: availableVersion })}
</p>
{releaseNotes && (
<div className="mt-2 max-h-40 overflow-y-auto rounded-lg border border-[var(--color-border)]/60 bg-[var(--color-surface)]/70 px-3 py-2">
<MarkdownRenderer
content={releaseNotes}
className="text-xs leading-5 text-[var(--color-text-secondary)] [&_h1]:mb-2 [&_h1]:text-sm [&_h1]:font-semibold [&_h2]:mb-1.5 [&_h2]:text-xs [&_h2]:font-semibold [&_p]:my-1.5 [&_p]:text-xs [&_p]:leading-5 [&_ul]:my-1.5 [&_ol]:my-1.5"
/>
</div>
)}
{(status === 'downloading' || status === 'restarting') && (
<div className="mt-3">
<div className="h-1.5 bg-[var(--color-surface)] rounded-full overflow-hidden">
{hasKnownProgress || status === 'restarting' ? (
<div
className="h-full bg-[var(--color-text-accent)] transition-all duration-300"
style={{ width: `${Math.min(progressPercent, 100)}%` }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-[var(--color-text-accent)]/75 animate-pulse" />
)}
</div>
{statusText && (
<p className="text-xs text-[var(--color-text-tertiary)] mt-1">
{statusText}
{status === 'downloading' && hasKnownProgress ? ` ${progressPercent}%` : ''}
</p>
)}
</div>
)}
{error && (
<p className="mt-2 text-xs text-[var(--color-error)]">
{t('update.failed', { error })}
</p>
)}
{status === 'available' && (
<div className="mt-3 flex gap-2">
<button
onClick={() => void installUpdate()}
className="px-3 py-1 text-xs font-medium rounded-[var(--radius-md)] bg-[var(--color-text-accent)] text-white hover:opacity-90 transition-opacity"
>
{t('update.now')}
</button>
<button
onClick={dismissPrompt}
className="px-3 py-1 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
>
{t('update.later')}
</button>
</div>
)}
</div>
</div>
)
}
@@ -0,0 +1,57 @@
import { useTranslation } from '../../i18n'
type Props = {
selected: number[]
onChange: (days: number[]) => void
}
// Display order: Mon(1) → Sun(0), matching Chinese convention
const DAY_ORDER = [1, 2, 3, 4, 5, 6, 0]
const DAY_KEYS = [
'newTask.daySun',
'newTask.dayMon',
'newTask.dayTue',
'newTask.dayWed',
'newTask.dayThu',
'newTask.dayFri',
'newTask.daySat',
] as const
export function DayOfWeekPicker({ selected, onChange }: Props) {
const t = useTranslation()
const toggle = (day: number) => {
if (selected.includes(day)) {
// Don't allow deselecting the last day
if (selected.length <= 1) return
onChange(selected.filter((d) => d !== day))
} else {
onChange([...selected, day])
}
}
return (
<div className="flex gap-1.5">
{DAY_ORDER.map((day) => {
const isActive = selected.includes(day)
return (
<button
key={day}
type="button"
onClick={() => toggle(day)}
className={`
w-8 h-8 rounded-full text-xs font-medium transition-colors
${isActive
? 'bg-[var(--color-surface-selected)] text-[var(--color-text-primary)] border border-[var(--color-border-focus)]'
: 'bg-[var(--color-surface)] text-[var(--color-text-tertiary)] border border-[var(--color-border)] hover:bg-[var(--color-surface-hover)]'
}
`}
>
{t(DAY_KEYS[day]!)}
</button>
)
})}
</div>
)
}
@@ -0,0 +1,219 @@
import { useEffect, useMemo } from 'react'
import { useTabStore } from '../stores/tabStore'
import { useSessionStore } from '../stores/sessionStore'
import { useChatStore } from '../stores/chatStore'
import { useCLITaskStore } from '../stores/cliTaskStore'
import { useTeamStore } from '../stores/teamStore'
import { useTranslation } from '../i18n'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { ComputerUsePermissionModal } from '../components/chat/ComputerUsePermissionModal'
import { TeamStatusBar } from '../components/teams/TeamStatusBar'
import { SessionTaskBar } from '../components/chat/SessionTaskBar'
const TASK_POLL_INTERVAL_MS = 1000
export function ActiveSession() {
const activeTabId = useTabStore((s) => s.activeTabId)
const sessions = useSessionStore((s) => s.sessions)
const connectToSession = useChatStore((s) => s.connectToSession)
const sessionState = useChatStore((s) => activeTabId ? s.sessions[activeTabId] : undefined)
const pendingComputerUsePermission = sessionState?.pendingComputerUsePermission ?? null
const fetchSessionTasks = useCLITaskStore((s) => s.fetchSessionTasks)
const trackedTaskSessionId = useCLITaskStore((s) => s.sessionId)
const hasIncompleteTasks = useCLITaskStore((s) => s.tasks.some((task) => task.status !== 'completed'))
const chatState = sessionState?.chatState ?? 'idle'
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const session = sessions.find((s) => s.id === activeTabId)
const memberInfo = useTeamStore((s) => activeTabId ? s.getMemberBySessionId(activeTabId) : null)
const activeTeam = useTeamStore((s) => s.activeTeam)
const isMemberSession = !!memberInfo
useEffect(() => {
if (activeTabId && !isMemberSession) {
connectToSession(activeTabId)
}
}, [activeTabId, isMemberSession, connectToSession])
useEffect(() => {
if (!activeTabId || isMemberSession) return
const shouldPollTasks =
chatState !== 'idle' ||
(trackedTaskSessionId === activeTabId && hasIncompleteTasks)
if (!shouldPollTasks) return
void fetchSessionTasks(activeTabId)
const timer = setInterval(() => {
void fetchSessionTasks(activeTabId)
}, TASK_POLL_INTERVAL_MS)
return () => clearInterval(timer)
}, [
activeTabId,
isMemberSession,
chatState,
trackedTaskSessionId,
hasIncompleteTasks,
fetchSessionTasks,
])
const t = useTranslation()
const messages = sessionState?.messages ?? []
const streamingText = sessionState?.streamingText ?? ''
const isEmpty = messages.length === 0 && !streamingText
const isActive = chatState !== 'idle'
const totalTokens = tokenUsage.input_tokens + tokenUsage.output_tokens
const lastUpdated = useMemo(() => {
if (!session?.modifiedAt) return ''
const diff = Date.now() - new Date(session.modifiedAt).getTime()
if (diff < 60000) return t('session.timeJustNow')
if (diff < 3600000) return t('session.timeMinutes', { n: Math.floor(diff / 60000) })
if (diff < 86400000) return t('session.timeHours', { n: Math.floor(diff / 3600000) })
return t('session.timeDays', { n: Math.floor(diff / 86400000) })
}, [session?.modifiedAt, t])
if (!activeTabId) return null
return (
<div className="flex-1 flex flex-col relative overflow-hidden bg-background text-on-surface">
{isMemberSession && (
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container)]">
<div className="mx-auto max-w-[860px] flex items-center justify-between gap-4 px-8 py-2">
<div className="min-w-0">
<div className="flex items-center gap-3">
{memberInfo?.status === 'running' && (
<span className="flex h-2 w-2 rounded-full bg-[var(--color-warning)] animate-pulse-dot" />
)}
{memberInfo?.status === 'completed' && (
<span className="material-symbols-outlined text-[14px] text-[var(--color-success)]" style={{ fontVariationSettings: "'FILL' 1" }}>check_circle</span>
)}
<span className="material-symbols-outlined text-[14px] text-[var(--color-text-tertiary)]">smart_toy</span>
<span className="text-sm font-semibold text-[var(--color-text-primary)]">
{memberInfo?.role}
</span>
{activeTeam && (
<span className="text-[10px] text-[var(--color-text-tertiary)]">
@ {activeTeam.name}
</span>
)}
</div>
<p className="mt-1 text-[11px] text-[var(--color-text-tertiary)]">
{t('teams.memberSessionHint')}
</p>
</div>
<button
onClick={() => {
if (activeTeam?.leadSessionId) {
useTabStore.getState().openTab(
activeTeam.leadSessionId,
t('teams.leader'),
'session',
)
}
}}
disabled={!activeTeam?.leadSessionId}
className="flex shrink-0 items-center gap-1 text-xs font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors disabled:opacity-50 disabled:hover:text-[var(--color-text-secondary)]"
>
<span className="material-symbols-outlined text-[14px]">arrow_back</span>
{t('teams.backToLeader')}
</button>
</div>
</div>
)}
{isEmpty ? (
<div className="flex flex-1 flex-col items-center justify-center p-8 pb-32">
<div className="flex max-w-md flex-col items-center text-center">
{isMemberSession ? (
<>
<span className="material-symbols-outlined text-[48px] mb-4 text-[var(--color-text-tertiary)]">smart_toy</span>
<p className="text-[var(--color-text-secondary)]">
{memberInfo?.status === 'running'
? `${memberInfo.role} ${t('teams.working')}`
: t('teams.noMessages')}
</p>
</>
) : (
<>
<img src="/app-icon.png" alt="Claude Code Haha" className="mb-6 h-24 w-24" />
<h1 className="mb-2 text-3xl font-extrabold tracking-tight text-[var(--color-text-primary)]" style={{ fontFamily: 'var(--font-headline)' }}>
{t('empty.title')}
</h1>
<p className="mx-auto max-w-xs text-[var(--color-text-secondary)]" style={{ fontFamily: 'var(--font-body)' }}>
{t('empty.subtitle')}
</p>
</>
)}
</div>
</div>
) : (
<>
{!isMemberSession && (
<div className="mx-auto flex w-full max-w-[860px] items-center border-b border-outline-variant/10 px-8 py-3">
<div className="flex-1">
<h1 className="text-lg font-bold font-headline text-on-surface leading-tight">
{session?.title || t('session.untitled')}
</h1>
<div className="flex items-center gap-2 text-[10px] text-outline font-medium mt-1">
{isActive && (
<span className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-success)] animate-pulse-dot" />
{t('session.active')}
</span>
)}
{totalTokens > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{totalTokens.toLocaleString()} t</span>
</>
)}
{lastUpdated && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.lastUpdated', { time: lastUpdated })}</span>
</>
)}
{session?.messageCount !== undefined && session.messageCount > 0 && (
<>
<span className="text-[var(--color-outline)]">·</span>
<span>{t('session.messages', { count: session.messageCount })}</span>
</>
)}
</div>
{session?.workDirExists === false && (
<div className="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-[var(--color-error)]/20 bg-[var(--color-error)]/8 px-3 py-1.5 text-[11px] text-[var(--color-error)]">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="truncate">
{t('session.workspaceUnavailable', { dir: session.workDir || 'directory no longer exists' })}
</span>
</div>
)}
</div>
</div>
)}
<MessageList />
</>
)}
{!isMemberSession && <SessionTaskBar />}
<TeamStatusBar />
<ChatInput variant={isEmpty && !isMemberSession ? 'hero' : 'default'} />
{!isMemberSession && activeTabId ? (
<ComputerUsePermissionModal
sessionId={activeTabId}
request={pendingComputerUsePermission?.request ?? null}
/>
) : null}
</div>
)
}
@@ -0,0 +1,416 @@
import { useTranslation } from '../i18n'
import { mockScheduledTasks, mockStatusBar } from '../mocks/data'
export function ScheduledTasksList() {
const t = useTranslation()
const { stats, tasks } = mockScheduledTasks
const task0 = tasks[0]!
const task1 = tasks[1]!
const task2 = tasks[2]!
return (
<div className="bg-[#FAF9F5] text-[#1B1C1A] flex min-h-screen overflow-hidden font-[Inter,sans-serif]">
{/* SideNavBar */}
<aside className="fixed left-0 top-0 h-full w-[280px] bg-[#F4F4F0] flex flex-col p-4 gap-2 z-40">
<div className="mb-6 px-2 flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-[#AD5F45] flex items-center justify-center">
<span
className="material-symbols-outlined text-white"
style={{ fontVariationSettings: "'FILL' 1" }}
>
folder_managed
</span>
</div>
<div>
<h2 className="font-[Manrope,sans-serif] text-sm font-bold text-[#1B1C1A] uppercase tracking-tighter">{t('sidebar.allProjects')}</h2>
<p className="text-xs text-[#87736D] font-medium">{t('scheduledPage.activeSession')}</p>
</div>
</div>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">add</span>
{t('sidebar.newSession')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full bg-[#FAF9F5] text-[#1B1C1A] rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-[#8F482F] before:rounded-full font-medium text-sm duration-200 ease-in-out">
<span
className="material-symbols-outlined"
style={{ fontVariationSettings: "'FILL' 1" }}
>
calendar_today
</span>
{t('sidebar.scheduled')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">history</span>
{t('sidebar.timeGroup.today')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">event_note</span>
{t('sidebar.timeGroup.last7days')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">archive</span>
{t('sidebar.timeGroup.older')}
</button>
<div className="mt-auto pt-4 flex flex-col gap-2">
<div className="px-2 py-4">
<button className="w-full bg-[#E9E8E4] text-[#1B1C1A] font-[Manrope,sans-serif] text-xs font-bold py-2 rounded-lg flex items-center justify-center gap-2 hover:bg-[#E3E2DF] transition-colors">
<span className="material-symbols-outlined text-[1rem]">search</span>
{t('sidebar.searchPlaceholder')}
</button>
</div>
<div className="h-[1px] bg-[#DAC1BA]/20 mx-2 mb-2"></div>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">computer</span>
{t('scheduledPage.localMode')}
</button>
<button className="flex items-center gap-3 px-3 py-2 w-full text-[#87736D] hover:bg-[#EBEBE6] transition-all rounded-lg font-medium text-sm duration-200 ease-in-out">
<span className="material-symbols-outlined">cloud</span>
{t('scheduledPage.remoteMode')}
</button>
</div>
</aside>
{/* Main Content Area */}
<main className="flex-1 flex flex-col ml-[280px] min-w-0 h-screen">
{/* TopAppBar */}
<header className="bg-[#FAF9F5] h-12 w-full flex justify-between items-center px-6 z-30">
<div className="flex items-center gap-8">
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] uppercase tracking-tighter text-sm">Claude Code Companion</div>
<nav className="flex items-center gap-6 font-[Manrope,sans-serif] font-semibold tracking-wide text-sm">
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">{t('titlebar.code')}</a>
<a className="text-[#87736D] hover:text-[#8F482F] transition-colors" href="#">{t('titlebar.terminal')}</a>
<a className="text-[#1B1C1A] border-b-2 border-[#8F482F] pb-1" href="#">{t('titlebar.history')}</a>
</nav>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<button className="p-1 text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
<span className="material-symbols-outlined text-[1rem]">arrow_back_ios</span>
</button>
<button className="p-1 text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70">
<span className="material-symbols-outlined text-[1rem]">arrow_forward_ios</span>
</button>
</div>
<button className="font-[Manrope,sans-serif] font-semibold tracking-wide text-sm text-[#87736D] hover:text-[#8F482F] transition-colors cursor-pointer active:opacity-70 flex items-center gap-1">
<span className="material-symbols-outlined text-[1.1rem]">settings</span>
{t('sidebar.settings')}
</button>
</div>
</header>
{/* Separation Line */}
<div className="bg-[#F4F4F0] h-[1px] w-full"></div>
{/* Scrollable Content */}
<section className="flex-1 overflow-y-auto p-12 bg-[#FAF9F5]">
<div className="max-w-5xl mx-auto">
{/* Page Header */}
<div className="flex justify-between items-end mb-12">
<div className="space-y-1">
<h1 className="font-[Manrope,sans-serif] text-3xl font-bold tracking-tight text-[#1B1C1A]">{t('scheduledPage.title')}</h1>
<p className="text-[#87736D] text-sm">{t('scheduledPage.subtitle')}</p>
</div>
<button className="bg-[#8F482F] hover:bg-[#AD5F45] text-white px-5 py-2.5 rounded-lg flex items-center gap-2 transition-all shadow-sm font-medium text-sm">
<span className="material-symbols-outlined text-[1.1rem]">add_task</span>
{t('tasks.createNew')}
</button>
</div>
{/* Bento-style Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-10">
{/* Total Tasks */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('tasks.totalTasks')}</span>
<span className="material-symbols-outlined text-[#8F482F]">analytics</span>
</div>
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.totalTasks}</div>
<div className="mt-2 flex items-center gap-1 text-[10px] text-[#4F6237] font-bold bg-[#677B4E]/20 px-2 py-0.5 rounded-full w-fit">
<span className="material-symbols-outlined text-[10px]">trending_up</span>
{t('scheduledPage.thisMonth', { count: '+2' })}
</div>
</div>
{/* Next Run */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('scheduledPage.nextRun')}</span>
<span className="material-symbols-outlined text-[#2D628F]">schedule</span>
</div>
<div className="text-xl font-[Manrope,sans-serif] font-bold text-[#1B1C1A]">{stats.nextRun.name}</div>
<p className="text-sm font-[JetBrains_Mono,monospace] text-[#2D628F] mt-1">{stats.nextRun.time}</p>
</div>
{/* System Health */}
<div className="bg-[#F4F4F0] p-6 rounded-xl border border-[#DAC1BA]/10">
<div className="flex items-center justify-between mb-4">
<span className="text-xs font-bold uppercase tracking-widest text-[#87736D]">{t('scheduledPage.systemHealth')}</span>
<span className="material-symbols-outlined text-[#4F6237]">check_circle</span>
</div>
<div className="text-4xl font-[Manrope,sans-serif] font-extrabold text-[#1B1C1A]">{stats.systemHealth}%</div>
<p className="text-xs text-[#87736D] mt-2 font-medium">{stats.healthPeriod}</p>
</div>
</div>
{/* Operational Tasks Table */}
<div className="bg-white rounded-xl overflow-hidden border border-[#DAC1BA]/20 shadow-[0_4px_20px_rgba(27,28,26,0.04)]">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-[#F4F4F0]/50">
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colTaskName')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colFrequency')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colLastResult')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10">{t('scheduledPage.colNextExecution')}</th>
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-[#DAC1BA] border-b border-[#DAC1BA]/10 text-right">{t('scheduledPage.colActions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#DAC1BA]/5">
{/* Task Row 1 - Nightly linting */}
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
<td className="px-6 py-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#FFDBD0] text-[#8F482F] rounded-lg">
<span className="material-symbols-outlined text-[1.2rem]">code_blocks</span>
</div>
<div>
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task0.name}</div>
<div className="text-xs text-[#87736D] font-medium">Root: /projects/companion/src</div>
</div>
</div>
</td>
<td className="px-6 py-5">
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">{task0.frequency}</span>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-1.5 text-[#4F6237] text-xs font-bold">
<span
className="material-symbols-outlined text-[1rem]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
{task0.lastResult}
</div>
</td>
<td className="px-6 py-5">
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task0.nextExecution}</div>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
</button>
</div>
</td>
</tr>
{/* Task Row 2 - Clean up temp files */}
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
<td className="px-6 py-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#CFE5FF] text-[#094A76] rounded-lg">
<span className="material-symbols-outlined text-[1.2rem]">cleaning_services</span>
</div>
<div>
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task1.name}</div>
<div className="text-xs text-[#87736D] font-medium">{task1.description}</div>
</div>
</div>
</td>
<td className="px-6 py-5">
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">{task1.frequency}</span>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-1.5 text-[#4F6237] text-xs font-bold">
<span
className="material-symbols-outlined text-[1rem]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
check_circle
</span>
{task1.lastResult}
</div>
</td>
<td className="px-6 py-5">
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task1.nextExecution}</div>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
</button>
</div>
</td>
</tr>
{/* Task Row 3 - Database Vacuum */}
<tr className="group hover:bg-[#F4F4F0]/30 transition-colors">
<td className="px-6 py-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-[#D4EAB4] text-[#3B4C24] rounded-lg">
<span className="material-symbols-outlined text-[1.2rem]">database</span>
</div>
<div>
<div className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-sm">{task2.name}</div>
<div className="text-xs text-[#87736D] font-medium">{task2.description}</div>
</div>
</div>
</td>
<td className="px-6 py-5">
<span className="px-2.5 py-1 bg-[#E9E8E4] rounded-full text-xs font-semibold text-[#54433E]">Monthly</span>
</td>
<td className="px-6 py-5">
<div className="flex items-center gap-1.5 text-[#BA1A1A] text-xs font-bold">
<span
className="material-symbols-outlined text-[1rem]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
error
</span>
{task2.lastResult}
</div>
</td>
<td className="px-6 py-5">
<div className="font-[JetBrains_Mono,monospace] text-sm font-medium text-[#2D628F]">{task2.nextExecution}</div>
</td>
<td className="px-6 py-5 text-right">
<div className="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button className="p-2 text-[#87736D] hover:text-[#8F482F] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">edit</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#BA1A1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">delete</span>
</button>
<button className="p-2 text-[#87736D] hover:text-[#1B1C1A] transition-colors">
<span className="material-symbols-outlined text-[1.1rem]">more_vert</span>
</button>
</div>
</td>
</tr>
</tbody>
</table>
{/* End of list placeholder */}
<div className="p-12 text-center border-t border-[#DAC1BA]/10">
<div className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-[#F4F4F0] mb-4">
<span className="material-symbols-outlined text-[#87736D]">history_toggle_off</span>
</div>
<h3 className="font-[Manrope,sans-serif] font-bold text-[#1B1C1A] text-base">{t('scheduledPage.endOfList')}</h3>
<p className="text-sm text-[#87736D] max-w-xs mx-auto mt-1">{t('scheduledPage.pausedTasks')}</p>
</div>
</div>
{/* System Logs / Details Panel */}
<div className="mt-12 flex flex-col md:flex-row gap-8 items-start">
{/* Recent Output Logs */}
<div className="flex-1 space-y-6">
<h2 className="font-[Manrope,sans-serif] text-lg font-bold text-[#1B1C1A]">{t('scheduledPage.recentLogs')}</h2>
<div className="bg-[#DBDAD6] rounded-xl p-6 font-[JetBrains_Mono,monospace] text-[13px] leading-relaxed text-[#54433E] overflow-x-auto shadow-inner">
<div className="flex gap-4 opacity-50 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:01</span>
<span className="text-[#4F6237]">[INFO]</span>
<span>Nightly linting started for repository: companion-main</span>
</div>
<div className="flex gap-4 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:04</span>
<span className="text-[#4F6237]">[INFO]</span>
<span>Processed 1,422 files. No critical issues found.</span>
</div>
<div className="flex gap-4 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:04</span>
<span className="text-[#094A76]">[WARN]</span>
<span className="italic">Found 12 deprecated calls in /legacy/utils.js</span>
</div>
<div className="flex gap-4 mb-1">
<span className="w-32 shrink-0">2023-11-10 23:05</span>
<span className="text-[#4F6237]">[INFO]</span>
<span>Task completed successfully in 242.4s.</span>
</div>
<div className="mt-4 pt-4 border-t border-[#DAC1BA]/20 flex items-center justify-between">
<span className="text-[11px] uppercase tracking-tighter opacity-50">Log stream: active</span>
<button className="text-[#8F482F] font-bold text-xs hover:underline">{t('scheduledPage.viewArtifacts')}</button>
</div>
</div>
</div>
{/* Resource Allocation Panel */}
<div className="w-full md:w-80 shrink-0">
<div className="bg-[#AD5F45]/10 p-6 rounded-xl border border-[#8F482F]/10">
<h3 className="font-[Manrope,sans-serif] font-bold text-[#8F482F] text-sm mb-3">{t('scheduledPage.resourceAllocation')}</h3>
<div className="space-y-4">
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
<span>{t('scheduledPage.cpuCapacity')}</span>
<span>42%</span>
</div>
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
<div className="h-full bg-[#8F482F]" style={{ width: '42%' }}></div>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between text-[11px] font-bold text-[#87736D] uppercase tracking-wider">
<span>{t('scheduledPage.memoryLoad')}</span>
<span>68%</span>
</div>
<div className="w-full h-1 bg-[#DAC1BA]/30 rounded-full overflow-hidden">
<div className="h-full bg-[#2D628F]" style={{ width: '68%' }}></div>
</div>
</div>
</div>
<div className="mt-6">
<div className="w-full h-24 rounded-lg bg-gradient-to-br from-[#FFDBD0] via-[#FFB59D]/40 to-[#DAC1BA]/20"></div>
</div>
</div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="bg-[#FAF9F5] border-t border-[#87736D]/20 fixed bottom-0 left-0 w-full h-8 flex items-center justify-between px-4 z-50">
<div className="flex items-center gap-4">
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D]">{mockStatusBar.user} &bull; {mockStatusBar.username} &bull; {mockStatusBar.plan}</span>
<div className="h-3 w-[1px] bg-[#87736D]/30"></div>
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-[10px] text-[#4F6237]"
style={{ fontVariationSettings: "'FILL' 1" }}
>
fiber_manual_record
</span>
<span className="font-[Inter,sans-serif] text-xs tracking-tight text-[#1B1C1A]">{t('scheduledPage.connectedLocal')}</span>
</div>
</div>
<div className="flex items-center gap-6">
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">account_tree</span>
{mockStatusBar.branch}
</button>
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#87736D] hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">layers</span>
{mockStatusBar.worktreeToggle}
</button>
<button className="font-[Inter,sans-serif] text-xs tracking-tight text-[#8F482F] font-bold hover:bg-[#F4F4F0] px-2 py-0.5 rounded transition-colors flex items-center gap-1">
<span className="material-symbols-outlined text-[12px]">toggle_on</span>
{mockStatusBar.localSwitch}
</button>
</div>
</footer>
</main>
</div>
)
}
@@ -0,0 +1,45 @@
import { create } from 'zustand'
import { agentsApi, type AgentDefinition } from '../api/agents'
export type AgentDetailReturnTab = 'agents' | 'plugins'
type AgentStore = {
activeAgents: AgentDefinition[]
allAgents: AgentDefinition[]
isLoading: boolean
error: string | null
selectedAgent: AgentDefinition | null
selectedAgentReturnTab: AgentDetailReturnTab
fetchAgents: (cwd?: string) => Promise<void>
selectAgent: (
agent: AgentDefinition | null,
returnTab?: AgentDetailReturnTab,
) => void
}
export const useAgentStore = create<AgentStore>((set) => ({
activeAgents: [],
allAgents: [],
isLoading: false,
error: null,
selectedAgent: null,
selectedAgentReturnTab: 'agents',
fetchAgents: async (cwd) => {
set({ isLoading: true, error: null })
try {
const { activeAgents, allAgents } = await agentsApi.list(cwd)
set({ activeAgents, allAgents, isLoading: false })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to load agents'
set({ isLoading: false, error: message })
}
},
selectAgent: (agent, returnTab = 'agents') =>
set({
selectedAgent: agent,
selectedAgentReturnTab: agent ? returnTab : 'agents',
}),
}))
@@ -0,0 +1,56 @@
// Source: src/server/services/cronService.ts
export type TaskNotificationConfig = {
enabled: boolean
channels: ('telegram' | 'feishu')[]
}
export type CronTask = {
id: string
name: string
description?: string
cron: string
prompt: string
enabled: boolean
recurring?: boolean
permanent?: boolean
createdAt: number
lastRunAt?: number
lastFiredAt?: string
nextRunAt?: number
permissionMode?: string
model?: string
folderPath?: string
useWorktree?: boolean
notification?: TaskNotificationConfig
}
export type CreateTaskInput = {
name: string
description?: string
cron: string
prompt: string
enabled?: boolean
recurring?: boolean
permanent?: boolean
permissionMode?: string
model?: string
folderPath?: string
useWorktree?: boolean
notification?: TaskNotificationConfig
}
export type TaskRun = {
id: string
taskId: string
taskName: string
startedAt: string
completedAt?: string
status: 'running' | 'completed' | 'failed' | 'timeout'
prompt: string
output?: string
error?: string
exitCode?: number
durationMs?: number
sessionId?: string
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 547 KiB

@@ -0,0 +1,34 @@
# FAQ
## Q: `undefined is not an object (evaluating 'usage.input_tokens')`
**Cause**: `ANTHROPIC_BASE_URL` is misconfigured. The API endpoint is returning HTML or another non-JSON format instead of a valid Anthropic protocol response.
This project uses the **Anthropic Messages API protocol**. `ANTHROPIC_BASE_URL` must point to an endpoint compatible with Anthropic's `/v1/messages` interface. The Anthropic SDK automatically appends `/v1/messages` to the base URL, so:
- MiniMax: `ANTHROPIC_BASE_URL=https://api.minimaxi.com/anthropic` ✅
- OpenRouter: `ANTHROPIC_BASE_URL=https://openrouter.ai/api` ✅
- OpenRouter (wrong): `ANTHROPIC_BASE_URL=https://openrouter.ai/anthropic` ❌ (returns HTML)
If your model provider only supports the OpenAI protocol, you need a proxy like LiteLLM for protocol translation. See the [Third-Party Models Guide](./third-party-models.md).
## Q: `Cannot find package 'bundle'`
```
error: Cannot find package 'bundle' from '.../claude-code-haha/src/entrypoints/cli.tsx'
```
**Cause**: Your Bun version is too old and doesn't support the required `bun:bundle` built-in module.
**Fix**: Upgrade Bun to the latest version:
```bash
bun upgrade
```
## Q: How to use OpenAI / DeepSeek / Ollama or other non-Anthropic models?
This project only supports the Anthropic protocol. If your model provider doesn't natively support the Anthropic protocol, you need a proxy like [LiteLLM](https://github.com/BerriAI/litellm) for protocol translation (OpenAI → Anthropic).
See the [Third-Party Models Guide](./third-party-models.md) for detailed setup instructions.
Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

@@ -0,0 +1,64 @@
# 快速开始
## 1. 安装 Bun
```bash
# macOS / Linux
curl -fsSL https://bun.sh/install | bash
# macOS (Homebrew)
brew install bun
# Windows (PowerShell)
powershell -c "irm bun.sh/install.ps1 | iex"
```
> 精简版 Linux 如提示 `unzip is required`,先运行 `apt update && apt install -y unzip`
## 2. 安装依赖并配置
```bash
bun install
cp .env.example .env
# 编辑 .env 填入你的 API Key,详见「环境变量」文档
```
环境变量的完整说明请参考 [环境变量配置](./env-vars.md)。
## 3. 启动
### macOS / Linux
```bash
./bin/claude-haha # 交互 TUI 模式
./bin/claude-haha -p "your prompt here" # 无头模式
./bin/claude-haha --help # 查看所有选项
```
### Windows
> **前置要求**:必须安装 [Git for Windows](https://git-scm.com/download/win)
```powershell
# PowerShell / cmd 直接调用 Bun
bun --env-file=.env ./src/entrypoints/cli.tsx
# 或在 Git Bash 中运行
./bin/claude-haha
```
## 4. 全局使用(可选)
将 `bin/` 加入 PATH 后可在任意目录启动,详见 [全局使用指南](./global-usage.md):
```bash
export PATH="$HOME/path/to/claude-code-haha/bin:$PATH"
```
## 5. 降级模式
如果 Ink TUI 出现问题,可以使用降级 Recovery CLI 模式:
```bash
CLAUDE_CODE_FORCE_RECOVERY_CLI=1 ./bin/claude-haha
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 710 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

@@ -0,0 +1,316 @@
# Claude Code 记忆系统 — AutoDream 记忆整合
> Claude 会"做梦"——在后台静默回顾近期会话,整合、更新、修剪记忆,就像人类睡眠中整理白天的记忆一样。
<p align="center">
<a href="#一什么是-autodream">AutoDream</a> · <a href="#二触发条件">触发条件</a> · <a href="#三四阶段整合流程">整合流程</a> · <a href="#四安全限制">安全限制</a> · <a href="#五ui-展示">UI 展示</a> · <a href="#六配置与开关">配置开关</a> · <a href="#七与-extractmemories-的关系">对比</a> · <a href="#八源码导航">源码导航</a>
</p>
![AutoDream 概览](./images/11-autodream-overview.png)
---
## 一、什么是 AutoDream?
AutoDream 是 Claude Code 的 **后台记忆整合机制**,内部代号 **"Dream: Memory Consolidation"**。
核心隐喻:
| 人类 | Claude Code |
|------|-------------|
| 白天随手记笔记 | `extractMemories` — 每次对话后提取新记忆 |
| 晚上睡觉时整理笔记本 | `autoDream` — 定期回顾多个会话,整合全部记忆 |
当你不活跃时(默认间隔 24 小时、积累 5 个会话后),Claude 会在后台静默启动一个 **"做梦"子智能体**(forked subagent),回顾所有近期会话记录,将零散的记忆整合为结构化的、去重的、去过时的持久化知识。
**关键源码**:`src/services/autoDream/autoDream.ts`
```typescript
// Background memory consolidation. Fires the /dream prompt as a forked
// subagent when time-gate passes AND enough sessions have accumulated.
```
---
## 二、触发条件
![AutoDream 触发流程](./images/12-autodream-trigger.png)
AutoDream 采用 **五重门控** 机制,按开销从低到高逐级检查:
### 门控链
| 序号 | 门控 | 说明 | 开销 |
|------|------|------|------|
| 1 | **功能开关** | `isAutoDreamEnabled()` + 非 KAIROS + 非远程模式 + autoMemory 已启用 | 内存读取 |
| 2 | **时间门控** | 距上次整合 >= `minHours`(默认 24h) | 1 次 stat |
| 3 | **扫描节流** | 上次扫描后至少等 10 分钟再重新扫描 | 时间戳比较 |
| 4 | **会话门控** | 自上次整合后至少 `minSessions` 个新会话(默认 5 个,排除当前) | 目录扫描 |
| 5 | **锁门控** | 没有其他进程正在做梦(PID 锁文件) | stat + read |
**关键源码** `src/services/autoDream/autoDream.ts:63-66`:
```typescript
const DEFAULTS: AutoDreamConfig = {
minHours: 24, // 距离上次整合至少 24 小时
minSessions: 5, // 期间至少积累了 5 个会话
}
```
### 扫描节流
当时间门控通过但会话门控未通过时,锁文件的 mtime 不会更新,导致时间门控在后续每个 turn 都会通过。为避免频繁的目录扫描,设有 **10 分钟的扫描节流**:
```typescript
const SESSION_SCAN_INTERVAL_MS = 10 * 60 * 1000
```
### 执行入口
AutoDream 在每次 AI 回复完成后的 stop hook 阶段被触发(fire-and-forget,不阻塞主线程):
**关键源码** `src/query/stopHooks.ts:154-156`:
```typescript
if (!toolUseContext.agentId) {
void executeAutoDream(stopHookContext, toolUseContext.appendSystemMessage)
}
```
### 阻止条件
以下情况不会触发 AutoDream:
- **KAIROS 模式**:使用独立的 disk-skill dream
- **远程模式**:`getIsRemoteMode() === true`
- **autoMemory 未启用**
- **`--bare` / SIMPLE 模式**
- **子代理内**:只有主代理才触发
---
## 三、四阶段整合流程
![AutoDream 四阶段流程](./images/13-autodream-phases.png)
一旦所有门控通过,AutoDream 启动一个 **分叉子智能体**,按照 `consolidationPrompt.ts` 定义的 4 阶段提示词工作:
### Phase 1 — Orient(定向)
```
- ls 记忆目录,查看已有文件
- 读取 MEMORY.md 索引,理解当前知识结构
- 浏览现有主题文件,避免创建重复
- 如存在 logs/ 或 sessions/ 子目录,检查最近条目
```
### Phase 2 — Gather recent signal(收集近期信号)
按优先级从高到低搜集信息:
1. **Daily logs** — `logs/YYYY/MM/YYYY-MM-DD.md`(追加流日志)
2. **漂移的记忆** — 与代码库现状矛盾的旧事实
3. **会话记录搜索** — 用 `grep` 在 JSONL 转录文件中窄范围检索
```
不要穷尽读取转录文件。只查找你已经怀疑重要的内容。
```
### Phase 3 — Consolidate(整合)
- **合并**新信号到已有主题文件(而非创建新的近似文件)
- **转换**相对日期为绝对日期("昨天" → "2026-04-03")
- **删除**被推翻的旧事实
### Phase 4 — Prune and index(修剪与索引)
- 更新 `MEMORY.md`,保持 ≤ 规定行数且 ≤ 25KB
- 删除指向过时记忆的指针
- 压缩冗长条目(>200 字符的索引行,将内容移入主题文件)
- 新增指向重要记忆的指针
- 解决矛盾(两个文件意见不一致时,修复错误的那个)
**关键源码** `src/services/autoDream/consolidationPrompt.ts:10-64`
---
## 四、安全限制
AutoDream 子智能体受到严格的工具权限限制:
### Bash 仅限只读
```
允许:ls, find, grep, cat, stat, wc, head, tail
拒绝:所有写入、重定向、修改状态的命令
```
### 文件操作仅限记忆目录
`createAutoMemCanUseTool()` 是 `extractMemories` 和 `autoDream` 共享的权限函数:
```
允许 Read / Grep / Glob — 无限制
允许 Edit / Write — 仅 auto-memory 目录内
拒绝 MCP / Agent / 非只读 Bash / 其他写操作
```
**关键源码** `src/services/extractMemories/extractMemories.ts:167-171`
### 锁文件机制
使用 `.consolidate-lock` 文件实现进程级互斥:
| 机制 | 说明 |
|------|------|
| **锁内容** | 持有者 PID |
| **时间戳** | 锁文件 mtime = 上次整合时间 |
| **过期** | 持有超过 1 小时视为过期(防 PID 复用) |
| **竞争** | 两个进程同时写入 → 最后写入者获胜,失败者在 re-read 时退出 |
| **回滚** | 失败时回退 mtime 到获取前的值,让下次尝试不受影响 |
| **崩溃恢复** | mtime 卡住 + 死 PID → 下个进程回收锁 |
**关键源码** `src/services/autoDream/consolidationLock.ts`
---
## 五、UI 展示
### 底部状态栏
当 AutoDream 运行时,底部状态栏会显示 **"dreaming"** 标签:
```typescript
// src/tasks/pillLabel.ts:61-62
case 'dream':
return 'dreaming'
```
### 任务详情对话框
用户按 `Shift+Down` 可打开后台任务对话框,查看梦境的实时进度:
- **DreamDetailDialog** 组件展示:
- 正在回顾的会话数量
- 当前阶段:`starting`(正在分析)→ `updating`(正在修改记忆文件)
- 最近的助手文本响应和工具调用次数
- 已触碰的文件路径列表
- **用户可按 `x` 键终止**正在进行的梦境(触发 abort + 锁回滚)
### 完成通知
梦境完成后,如果有文件被修改,会在主会话中显示内联通知:
```typescript
appendSystemMessage({
...createMemorySavedMessage(dreamState.filesTouched),
verb: 'Improved',
})
```
**关键源码**:
- `src/tasks/DreamTask/DreamTask.ts` — 任务状态管理
- `src/components/tasks/DreamDetailDialog.tsx` — UI 组件
---
## 六、配置与开关
### settings.json
```json
{
"autoDreamEnabled": true
}
```
- **显式设置时**:直接使用用户的值
- **未设置时**:由远程 GrowthBook feature flag `tengu_onyx_plover` 控制
**关键源码** `src/services/autoDream/config.ts:13-21`:
```typescript
export function isAutoDreamEnabled(): boolean {
const setting = getInitialSettings().autoDreamEnabled
if (setting !== undefined) return setting
const gb = getFeatureValue_CACHED_MAY_BE_STALE<{ enabled?: unknown } | null>(
'tengu_onyx_plover', null,
)
return gb?.enabled === true
}
```
### 远程配置参数
`tengu_onyx_plover` feature flag 可配置:
| 参数 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| `enabled` | boolean | — | 功能总开关 |
| `minHours` | number | 24 | 最小间隔(小时) |
| `minSessions` | number | 5 | 最小会话数 |
### 手动触发 `/dream`
除了自动触发,用户可以通过 `/dream` 命令手动触发记忆整合。手动触发会调用 `recordConsolidation()` 更新锁文件时间戳。
---
## 七、与 extractMemories 的关系
| 维度 | extractMemories | autoDream |
|------|----------------|-----------|
| **触发频率** | 每次对话回合结束 | 每 24h + 5 个会话 |
| **触发位置** | `stopHooks.ts` L149 | `stopHooks.ts` L155 |
| **处理范围** | 当前对话最近的消息 | 多个会话的历史记录 |
| **目标** | 提取新记忆 | 整合/去重/修剪已有记忆 |
| **人类类比** | 白天随手记笔记 | 睡觉时整理笔记本 |
| **共享组件** | `createAutoMemCanUseTool` | `createAutoMemCanUseTool` |
| **分叉代理** | 最多 5 turns | 无 turn 限制 |
| **转录** | 不记录(`skipTranscript`) | 不记录(`skipTranscript`) |
### 协作流
```
每次对话结束
↓
extractMemories → 提取新记忆片段 → 写入 *.md + MEMORY.md
↓
(积累 24h + 5个会话后)
↓
autoDream → 回顾所有记忆 + 会话记录
↓
合并重复 / 修正过时 / 删除矛盾 / 压缩索引
↓
MEMORY.md 和主题文件焕然一新
```
---
## 八、源码导航
| 文件 | 职责 |
|------|------|
| `src/services/autoDream/autoDream.ts` | 主逻辑:门控检查、启动分叉代理、进度监控 |
| `src/services/autoDream/config.ts` | 开关控制:settings.json 或 GrowthBook |
| `src/services/autoDream/consolidationPrompt.ts` | 梦境提示词:4 阶段整合工作流 |
| `src/services/autoDream/consolidationLock.ts` | 锁文件机制:防并发、时间戳、回滚 |
| `src/tasks/DreamTask/DreamTask.ts` | UI 任务注册:状态管理、终止、回滚 |
| `src/tasks/pillLabel.ts` | 底部状态栏标签:"dreaming" |
| `src/components/tasks/DreamDetailDialog.tsx` | 梦境详情对话框 UI |
| `src/query/stopHooks.ts` | 执行入口:每次回复后触发 |
| `src/utils/backgroundHousekeeping.ts` | 初始化入口:`initAutoDream()` |
| `src/services/extractMemories/extractMemories.ts` | 共享的 `createAutoMemCanUseTool` |
---
## 九、分析事件
AutoDream 通过以下事件记录运行状态:
| 事件 | 时机 | 附带数据 |
|------|------|----------|
| `tengu_auto_dream_fired` | 梦境启动 | `hours_since`, `sessions_since` |
| `tengu_auto_dream_completed` | 梦境完成 | `cache_read`, `cache_created`, `output`, `sessions_reviewed` |
| `tengu_auto_dream_failed` | 梦境失败 | — |
@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html class="light" lang="en"><head>
<meta charset="utf-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700;800&amp;family=Inter:wght@400;500;600&amp;family=JetBrains+Mono&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
<script id="tailwind-config">
tailwind.config = {
darkMode: "class",
theme: {
extend: {
"colors": {
"primary-fixed": "#ffdbd0",
"on-secondary-fixed-variant": "#094a76",
"on-primary": "#ffffff",
"on-surface": "#1b1c1a",
"error": "#ba1a1a",
"inverse-on-surface": "#f2f1ed",
"surface-container-lowest": "#ffffff",
"tertiary-fixed": "#d4eab4",
"secondary-container": "#9acbfe",
"on-tertiary": "#ffffff",
"primary-fixed-dim": "#ffb59d",
"secondary-fixed": "#cfe5ff",
"surface-container-low": "#f4f4f0",
"tertiary": "#4f6237",
"surface-dim": "#dbdad6",
"outline-variant": "#dac1ba",
"on-primary-fixed-variant": "#75331c",
"tertiary-container": "#677b4e",
"surface-container": "#efeeea",
"surface-variant": "#e3e2df",
"error-container": "#ffdad6",
"on-primary-container": "#fffbff",
"inverse-primary": "#ffb59d",
"on-error": "#ffffff",
"tertiary-fixed-dim": "#b8ce99",
"background": "#faf9f5",
"on-primary-fixed": "#390c00",
"on-secondary": "#ffffff",
"surface-tint": "#924a31",
"inverse-surface": "#2f312e",
"on-secondary-fixed": "#001d34",
"primary-container": "#ad5f45",
"on-surface-variant": "#54433e",
"surface-container-high": "#e9e8e4",
"surface": "#faf9f5",
"on-tertiary-container": "#faffea",
"primary": "#8f482f",
"on-tertiary-fixed": "#102000",
"surface-container-highest": "#e3e2df",
"secondary": "#2d628f",
"on-background": "#1b1c1a",
"on-secondary-container": "#1e5683",
"outline": "#87736d",
"surface-bright": "#faf9f5",
"on-tertiary-fixed-variant": "#3b4c24",
"secondary-fixed-dim": "#9acbfe",
"on-error-container": "#93000a"
},
"borderRadius": {
"DEFAULT": "0.25rem",
"lg": "0.875rem",
"xl": "1rem",
"full": "9999px"
},
"fontFamily": {
"headline": ["Manrope"],
"body": ["Inter"],
"label": ["Inter"],
"mono": ["JetBrains Mono"]
}
},
},
}
</script>
<style>
.material-symbols-outlined {
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
body { font-family: 'Inter', sans-serif; }
.font-headline { font-family: 'Manrope', sans-serif; }
.font-mono { font-family: 'JetBrains Mono', monospace; }
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: #dac1ba; border-radius: 10px; }
</style>
</head>
<body class="bg-background text-on-surface selection:bg-primary-fixed">
<!-- Sidebar Navigation -->
<aside class="fixed left-0 top-0 h-full w-[280px] bg-surface-container-low flex flex-col p-4 gap-2 z-40 hidden md:flex">
<div class="flex items-center gap-3 px-2 mb-6">
<div class="w-8 h-8 rounded bg-primary flex items-center justify-center text-on-primary">
<span class="material-symbols-outlined text-sm">code_blocks</span>
</div>
<div>
<div class="text-sm font-bold text-on-surface font-headline leading-tight">All projects</div>
<div class="text-[11px] text-outline font-medium">Active Session</div>
</div>
</div>
<button class="w-full py-2.5 px-4 mb-4 bg-surface-container-lowest border border-outline-variant/30 rounded-lg text-left text-sm text-outline flex items-center gap-2 hover:bg-white transition-all">
<span class="material-symbols-outlined text-sm">search</span>
<span>Search sessions</span>
</button>
<nav class="flex-1 space-y-1">
<div class="px-2 py-1 text-[10px] uppercase tracking-widest text-outline/60 font-bold mb-1">Navigation</div>
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
<span class="material-symbols-outlined text-lg">add</span>
New session
</a>
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
<span class="material-symbols-outlined text-lg">calendar_today</span>
Scheduled
</a>
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium bg-surface-container-lowest text-on-surface rounded-lg relative before:content-[''] before:absolute before:left-[-8px] before:w-1 before:h-4 before:bg-primary before:rounded-full" href="#">
<span class="material-symbols-outlined text-lg">history</span>
Today
</a>
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
<span class="material-symbols-outlined text-lg">event_note</span>
Previous 7 Days
</a>
<a class="flex items-center gap-3 px-3 py-2 text-sm font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
<span class="material-symbols-outlined text-lg">archive</span>
Older
</a>
</nav>
<div class="mt-auto pt-4 border-t border-outline-variant/20 space-y-1">
<a class="flex items-center gap-3 px-3 py-2 text-xs font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
<span class="material-symbols-outlined text-lg">computer</span>
Local Mode
</a>
<a class="flex items-center gap-3 px-3 py-2 text-xs font-medium text-outline hover:bg-surface-container-high transition-all rounded-lg" href="#">
<span class="material-symbols-outlined text-lg">cloud</span>
Remote Mode
</a>
</div>
</aside>
<!-- Main Content Canvas -->
<main class="md:ml-[280px] min-h-screen flex flex-col relative">
<!-- Top App Bar -->
<header class="sticky top-0 z-30 bg-background/80 backdrop-blur-md flex justify-between items-center px-6 h-12 w-full">
<div class="flex items-center gap-6">
<span class="text-sm font-bold text-on-surface uppercase tracking-tighter font-headline">Claude Code Companion</span>
<nav class="hidden lg:flex items-center gap-4">
<a class="text-sm font-semibold text-outline hover:text-primary transition-colors" href="#">Code</a>
<a class="text-sm font-semibold text-on-surface border-b-2 border-primary pb-1" href="#">Terminal</a>
<a class="text-sm font-semibold text-outline hover:text-primary transition-colors" href="#">History</a>
</nav>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<button class="p-1 text-outline hover:text-primary active:opacity-70 transition-colors">
<span class="material-symbols-outlined text-lg">arrow_back_ios</span>
</button>
<button class="p-1 text-outline hover:text-primary active:opacity-70 transition-colors">
<span class="material-symbols-outlined text-lg">arrow_forward_ios</span>
</button>
</div>
<button class="text-sm font-semibold text-outline hover:text-primary px-2 transition-colors">Settings</button>
</div>
</header>
<!-- Divider Line -->
<div class="bg-surface-container h-[1px] w-full"></div>
<!-- Editor Toolbar / Title Area -->
<div class="px-8 py-6 flex justify-between items-center max-w-4xl mx-auto w-full">
<div class="flex-1">
<input class="bg-transparent border-none focus:ring-0 text-2xl font-bold font-headline text-on-surface w-full p-0 leading-tight" type="text" value="Refactor login flow"/>
<div class="text-xs text-outline font-medium mt-1">session-active-042 • last updated 2m ago</div>
</div>
<button class="flex items-center gap-2 px-4 py-2 bg-surface-container-high hover:bg-surface-container-highest text-on-surface font-semibold text-sm rounded-lg transition-colors">
<span class="material-symbols-outlined text-sm">visibility</span>
Preview
</button>
</div>
<!-- Conversation Stream -->
<div class="flex-1 px-8 pb-40 max-w-4xl mx-auto w-full space-y-10">
<!-- User Bubble -->
<div class="flex justify-end">
<div class="max-w-[85%] bg-surface-container px-5 py-4 rounded-2xl rounded-tr-none shadow-sm">
<p class="text-sm leading-relaxed text-on-surface-variant font-medium">
I need to refactor the login flow in <code class="font-mono bg-surface-container-highest px-1 rounded">auth.ts</code>. Let's move the JWT signing logic to a separate helper and add validation for the user payload.
</p>
</div>
</div>
<!-- Assistant Response -->
<div class="flex gap-4">
<div class="w-8 h-8 shrink-0 rounded-full bg-primary flex items-center justify-center text-on-primary">
<span class="material-symbols-outlined text-sm" style="font-variation-settings: 'FILL' 1;">smart_toy</span>
</div>
<div class="flex-1 space-y-6">
<div class="prose prose-sm text-on-surface leading-relaxed max-w-none">
<p class="text-sm">Understood. I will begin by analyzing the current implementation of <code class="font-mono text-secondary">auth.ts</code>. I'll extract the JWT logic into a new utility function and implement the validation layer as requested.</p>
</div>
<!-- Collapsed Thinking Block -->
<div class="bg-surface-container-low border border-outline-variant/20 rounded-xl overflow-hidden">
<button class="w-full px-4 py-2.5 flex items-center justify-between text-xs font-semibold text-outline hover:bg-surface-container-high transition-colors">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-sm">psychology</span>
<span>Thinking Process</span>
</div>
<span class="material-symbols-outlined text-sm">expand_more</span>
</button>
</div>
<!-- Tool Use Block -->
<div class="bg-surface-dim rounded-xl p-4 space-y-3">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="material-symbols-outlined text-secondary text-base">edit_document</span>
<span class="text-xs font-mono font-bold text-on-surface">edit_file: auth.ts</span>
</div>
<span class="text-[10px] text-outline font-bold uppercase tracking-widest">Modified</span>
</div>
<div class="bg-surface-container-lowest/50 rounded-lg p-3 font-mono text-xs text-on-surface-variant border border-outline-variant/10">
<span class="text-tertiary">+ const validatePayload = (user) =&gt; { ... }</span><br/>
<span class="text-tertiary">+ export const signToken = (payload) =&gt; jwt.sign(payload, SECRET);</span><br/>
<span class="opacity-50">- // OLD LOGIC REMOVED</span>
</div>
</div>
<!-- Crafting Status Bar (Internal to Stream) -->
<div class="flex items-center justify-between bg-surface-container-lowest border border-primary/20 rounded-full px-4 py-2 shadow-sm">
<div class="flex items-center gap-3">
<div class="w-2 h-2 bg-primary rounded-full animate-pulse"></div>
<span class="text-xs font-bold font-headline text-primary">Crafting...</span>
<span class="w-[1px] h-3 bg-outline-variant/50"></span>
<span class="text-xs font-mono text-outline">00:42</span>
<span class="text-xs font-mono text-outline/60">1.2k tokens</span>
</div>
<button class="flex items-center gap-1.5 px-3 py-1 bg-error-container text-on-error-container text-[10px] font-bold rounded-full hover:bg-error/10 transition-colors">
<span class="material-symbols-outlined text-xs">stop</span>
Stop
</button>
</div>
</div>
</div>
</div>
<!-- Chat Composer (Floating Bottom) -->
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 md:left-[calc(50%+140px)] w-full max-w-2xl px-6 z-40">
<div class="bg-surface-container-lowest/85 backdrop-blur-xl border border-outline-variant/15 rounded-2xl shadow-lg p-3">
<div class="relative flex items-end gap-3">
<button class="p-2 text-outline hover:text-primary transition-colors">
<span class="material-symbols-outlined">attach_file</span>
</button>
<textarea class="w-full bg-transparent border-none focus:ring-0 resize-none py-2 text-sm text-on-surface placeholder:text-outline/50 min-h-[44px] max-h-48" placeholder="Ask Claude to edit, debug or explain..." rows="1"></textarea>
<button class="w-10 h-10 rounded-xl bg-primary text-on-primary flex items-center justify-center hover:shadow-md transition-all active:scale-95">
<span class="material-symbols-outlined">arrow_upward</span>
</button>
</div>
<div class="flex items-center gap-4 px-2 mt-2">
<button class="flex items-center gap-1.5 text-[10px] font-bold text-outline uppercase tracking-wider hover:text-primary transition-colors">
<span class="material-symbols-outlined text-sm">terminal</span>
Terminal
</button>
<button class="flex items-center gap-1.5 text-[10px] font-bold text-outline uppercase tracking-wider hover:text-primary transition-colors">
<span class="material-symbols-outlined text-sm">image</span>
Add Vision
</button>
<div class="ml-auto flex items-center gap-2">
<span class="text-[10px] text-outline/50 font-medium">Claude 3.5 Sonnet</span>
</div>
</div>
</div>
</div>
<!-- Global Footer Bar -->
<footer class="fixed bottom-0 left-0 w-full h-8 bg-background border-t border-outline-variant/20 flex items-center justify-between px-4 z-50">
<div class="flex items-center gap-4">
<span class="text-[10px] font-medium text-outline uppercase tracking-tighter">User Avatar • username • Pro Plan</span>
</div>
<div class="flex items-center gap-4">
<button class="flex items-center gap-1 text-[10px] text-outline hover:text-primary transition-colors">
<span class="material-symbols-outlined text-xs">account_tree</span>
main-branch
</button>
<button class="flex items-center gap-1 text-[10px] text-outline hover:text-primary transition-colors">
<span class="material-symbols-outlined text-xs">layers</span>
worktree-toggle
</button>
<button class="flex items-center gap-1 text-[10px] text-primary font-bold transition-colors">
<span class="material-symbols-outlined text-xs">sync_alt</span>
local-switch
</button>
</div>
</footer>
</main>
</body></html>
@@ -0,0 +1,35 @@
# Claude Code Haha v0.1.7
这是一个以 Windows 桌面端在线更新稳定性和聊天展示体验修复为主的补丁版本。
相比 `v0.1.6`,这次重点修复了 Windows 桌面端自动更新后本地服务启动失败的问题,并补齐了 Windows 开发环境 WebUI 启动 CLI 时的宏预加载逻辑。同时修复了 Mermaid 渲染错误提示和聊天手动滚动被打断的问题。
## 主要更新
- 修复 Windows 桌面端在线自动更新流程:安装新版本前会先停止本地 server sidecar 和 adapter sidecar,避免升级后重启时出现 `os error 32`、`另一个程序正在使用此文件` 或“本地服务启动失败”。
- Windows 发布产物改为优先使用 NSIS 安装包,并在安装器 hook 中处理 sidecar 进程清理,降低从旧版本自动升级时被文件锁卡住的概率。
- 修复 Windows 开发环境中 WebUI 启动 CLI 失败的问题,源码 fallback 启动 CLI 时会正确加载 `preload.ts`,避免 `MACRO is not defined` 导致进程启动失败。
- 优化聊天列表手动滚动体验:用户向上查看历史消息时,流式输出不会强制把视图拉回底部;当用户仍停留在底部附近时,会继续保持自动跟随最新回复。
## 问题修复
- 修复 Mermaid 图表渲染失败时可能出现开发错误覆盖层的问题,让错误状态保持在聊天消息内部展示。
- 修复聊天流式输出期间手动滚动位置被覆盖的问题,并补充相关回归测试。
- 修复 Windows updater 安装阶段没有提前释放 sidecar 文件句柄的问题。
- 修复 Windows dev WebUI 会话启动时 CLI 进程直接以 code 1 退出的问题。
## 其他说明
- 已安装 `v0.1.5` 或 `v0.1.6` 的 Windows 用户正常情况下可以直接升级到 `v0.1.7`,不需要先卸载旧版本。
- 如果某台机器已经卡在旧版本升级后的“本地服务启动失败”页面,可以先完全退出桌面端和残留的 `claude-sidecar.exe` 进程,再安装 `v0.1.7`。
- GitHub Release 正文继续以 `release-notes/v0.1.7.md` 作为来源,发布时无需再手动复制 Markdown。
## 安装说明
### macOS
首次打开如果提示“已损坏”或“无法验证开发者”,请执行:
```bash
xattr -cr /Applications/Claude\ Code\ Haha.app
```
@@ -0,0 +1,36 @@
import { updateSessionBridgeId } from '../utils/concurrentSessions.js'
import type { ReplBridgeHandle } from './replBridge.js'
import { toCompatSessionId } from './sessionIdCompat.js'
/**
* Global pointer to the active REPL bridge handle, so callers outside
* useReplBridge's React tree (tools, slash commands) can invoke handle methods
* like subscribePR. Same one-bridge-per-process justification as bridgeDebug.ts
* — the handle's closure captures the sessionId and getAccessToken that created
* the session, and re-deriving those independently (BriefTool/upload.ts pattern)
* risks staging/prod token divergence.
*
* Set from useReplBridge.tsx when init completes; cleared on teardown.
*/
let handle: ReplBridgeHandle | null = null
export function setReplBridgeHandle(h: ReplBridgeHandle | null): void {
handle = h
// Publish (or clear) our bridge session ID in the session record so other
// local peers can dedup us out of their bridge list — local is preferred.
void updateSessionBridgeId(getSelfBridgeCompatId() ?? null).catch(() => {})
}
export function getReplBridgeHandle(): ReplBridgeHandle | null {
return handle
}
/**
* Our own bridge session ID in the session_* compat format the API returns
* in /v1/sessions responses — or undefined if bridge isn't connected.
*/
export function getSelfBridgeCompatId(): string | undefined {
const h = getReplBridgeHandle()
return h ? toCompatSessionId(h.bridgeSessionId) : undefined
}
@@ -0,0 +1,550 @@
import { type ChildProcess, spawn } from 'child_process'
import { createWriteStream, type WriteStream } from 'fs'
import { tmpdir } from 'os'
import { dirname, join } from 'path'
import { createInterface } from 'readline'
import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
import { debugTruncate } from './debugUtils.js'
import type {
SessionActivity,
SessionDoneStatus,
SessionHandle,
SessionSpawner,
SessionSpawnOpts,
} from './types.js'
const MAX_ACTIVITIES = 10
const MAX_STDERR_LINES = 10
/**
* Sanitize a session ID for use in file names.
* Strips any characters that could cause path traversal (e.g. `../`, `/`)
* or other filesystem issues, replacing them with underscores.
*/
export function safeFilenameId(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, '_')
}
/**
* A control_request emitted by the child CLI when it needs permission to
* execute a **specific** tool invocation (not a general capability check).
* The bridge forwards this to the server so the user can approve/deny.
*/
export type PermissionRequest = {
type: 'control_request'
request_id: string
request: {
/** Per-invocation permission check — "may I run this tool with these inputs?" */
subtype: 'can_use_tool'
tool_name: string
input: Record<string, unknown>
tool_use_id: string
}
}
type SessionSpawnerDeps = {
execPath: string
/**
* Arguments that must precede the CLI flags when spawning. Empty for
* compiled binaries (where execPath is the claude binary itself); contains
* the script path (process.argv[1]) for npm installs where execPath is the
* node runtime. Without this, node sees --sdk-url as a node option and
* exits with "bad option: --sdk-url" (see anthropics/claude-code#28334).
*/
scriptArgs: string[]
env: NodeJS.ProcessEnv
verbose: boolean
sandbox: boolean
debugFile?: string
permissionMode?: string
onDebug: (msg: string) => void
onActivity?: (sessionId: string, activity: SessionActivity) => void
onPermissionRequest?: (
sessionId: string,
request: PermissionRequest,
accessToken: string,
) => void
}
/** Map tool names to human-readable verbs for the status display. */
const TOOL_VERBS: Record<string, string> = {
Read: 'Reading',
Write: 'Writing',
Edit: 'Editing',
MultiEdit: 'Editing',
Bash: 'Running',
Glob: 'Searching',
Grep: 'Searching',
WebFetch: 'Fetching',
WebSearch: 'Searching',
Task: 'Running task',
FileReadTool: 'Reading',
FileWriteTool: 'Writing',
FileEditTool: 'Editing',
GlobTool: 'Searching',
GrepTool: 'Searching',
BashTool: 'Running',
NotebookEditTool: 'Editing notebook',
LSP: 'LSP',
}
function toolSummary(name: string, input: Record<string, unknown>): string {
const verb = TOOL_VERBS[name] ?? name
const target =
(input.file_path as string) ??
(input.filePath as string) ??
(input.pattern as string) ??
(input.command as string | undefined)?.slice(0, 60) ??
(input.url as string) ??
(input.query as string) ??
''
if (target) {
return `${verb} ${target}`
}
return verb
}
function extractActivities(
line: string,
sessionId: string,
onDebug: (msg: string) => void,
): SessionActivity[] {
let parsed: unknown
try {
parsed = jsonParse(line)
} catch {
return []
}
if (!parsed || typeof parsed !== 'object') {
return []
}
const msg = parsed as Record<string, unknown>
const activities: SessionActivity[] = []
const now = Date.now()
switch (msg.type) {
case 'assistant': {
const message = msg.message as Record<string, unknown> | undefined
if (!message) break
const content = message.content
if (!Array.isArray(content)) break
for (const block of content) {
if (!block || typeof block !== 'object') continue
const b = block as Record<string, unknown>
if (b.type === 'tool_use') {
const name = (b.name as string) ?? 'Tool'
const input = (b.input as Record<string, unknown>) ?? {}
const summary = toolSummary(name, input)
activities.push({
type: 'tool_start',
summary,
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} tool_use name=${name} ${inputPreview(input)}`,
)
} else if (b.type === 'text') {
const text = (b.text as string) ?? ''
if (text.length > 0) {
activities.push({
type: 'text',
summary: text.slice(0, 80),
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} text "${text.slice(0, 100)}"`,
)
}
}
}
break
}
case 'result': {
const subtype = msg.subtype as string | undefined
if (subtype === 'success') {
activities.push({
type: 'result',
summary: 'Session completed',
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} result subtype=success`,
)
} else if (subtype) {
const errors = msg.errors as string[] | undefined
const errorSummary = errors?.[0] ?? `Error: ${subtype}`
activities.push({
type: 'error',
summary: errorSummary,
timestamp: now,
})
onDebug(
`[bridge:activity] sessionId=${sessionId} result subtype=${subtype} error="${errorSummary}"`,
)
} else {
onDebug(
`[bridge:activity] sessionId=${sessionId} result subtype=undefined`,
)
}
break
}
default:
break
}
return activities
}
/**
* Extract plain text from a replayed SDKUserMessage NDJSON line. Returns the
* trimmed text if this looks like a real human-authored message, otherwise
* undefined so the caller keeps waiting for the first real message.
*/
function extractUserMessageText(
msg: Record<string, unknown>,
): string | undefined {
// Skip tool-result user messages (wrapped subagent results) and synthetic
// caveat messages — neither is human-authored.
if (msg.parent_tool_use_id != null || msg.isSynthetic || msg.isReplay)
return undefined
const message = msg.message as Record<string, unknown> | undefined
const content = message?.content
let text: string | undefined
if (typeof content === 'string') {
text = content
} else if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === 'object' &&
(block as Record<string, unknown>).type === 'text'
) {
text = (block as Record<string, unknown>).text as string | undefined
break
}
}
}
text = text?.trim()
return text ? text : undefined
}
/** Build a short preview of tool input for debug logging. */
function inputPreview(input: Record<string, unknown>): string {
const parts: string[] = []
for (const [key, val] of Object.entries(input)) {
if (typeof val === 'string') {
parts.push(`${key}="${val.slice(0, 100)}"`)
}
if (parts.length >= 3) break
}
return parts.join(' ')
}
export function createSessionSpawner(deps: SessionSpawnerDeps): SessionSpawner {
return {
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle {
// Debug file resolution:
// 1. If deps.debugFile is provided, use it with session ID suffix for uniqueness
// 2. If verbose or ant build, auto-generate a temp file path
// 3. Otherwise, no debug file
const safeId = safeFilenameId(opts.sessionId)
let debugFile: string | undefined
if (deps.debugFile) {
const ext = deps.debugFile.lastIndexOf('.')
if (ext > 0) {
debugFile = `${deps.debugFile.slice(0, ext)}-${safeId}${deps.debugFile.slice(ext)}`
} else {
debugFile = `${deps.debugFile}-${safeId}`
}
} else if (deps.verbose || process.env.USER_TYPE === 'ant') {
debugFile = join(tmpdir(), 'claude', `bridge-session-${safeId}.log`)
}
// Transcript file: write raw NDJSON lines for post-hoc analysis.
// Placed alongside the debug file when one is configured.
let transcriptStream: WriteStream | null = null
let transcriptPath: string | undefined
if (deps.debugFile) {
transcriptPath = join(
dirname(deps.debugFile),
`bridge-transcript-${safeId}.jsonl`,
)
transcriptStream = createWriteStream(transcriptPath, { flags: 'a' })
transcriptStream.on('error', err => {
deps.onDebug(
`[bridge:session] Transcript write error: ${err.message}`,
)
transcriptStream = null
})
deps.onDebug(`[bridge:session] Transcript log: ${transcriptPath}`)
}
const args = [
...deps.scriptArgs,
'--print',
'--sdk-url',
opts.sdkUrl,
'--session-id',
opts.sessionId,
'--input-format',
'stream-json',
'--output-format',
'stream-json',
'--replay-user-messages',
...(deps.verbose ? ['--verbose'] : []),
...(debugFile ? ['--debug-file', debugFile] : []),
...(deps.permissionMode
? ['--permission-mode', deps.permissionMode]
: []),
]
const env: NodeJS.ProcessEnv = {
...deps.env,
// Strip the bridge's OAuth token so the child CC process uses
// the session access token for inference instead.
CLAUDE_CODE_OAUTH_TOKEN: undefined,
CLAUDE_CODE_ENVIRONMENT_KIND: 'bridge',
...(deps.sandbox && { CLAUDE_CODE_FORCE_SANDBOX: '1' }),
CLAUDE_CODE_SESSION_ACCESS_TOKEN: opts.accessToken,
// v1: HybridTransport (WS reads + POST writes) to Session-Ingress.
// Harmless in v2 mode — transportUtils checks CLAUDE_CODE_USE_CCR_V2 first.
CLAUDE_CODE_POST_FOR_SESSION_INGRESS_V2: '1',
// v2: SSETransport + CCRClient to CCR's /v1/code/sessions/* endpoints.
// Same env vars environment-manager sets in the container path.
...(opts.useCcrV2 && {
CLAUDE_CODE_USE_CCR_V2: '1',
CLAUDE_CODE_WORKER_EPOCH: String(opts.workerEpoch),
}),
}
deps.onDebug(
`[bridge:session] Spawning sessionId=${opts.sessionId} sdkUrl=${opts.sdkUrl} accessToken=${opts.accessToken ? 'present' : 'MISSING'}`,
)
deps.onDebug(`[bridge:session] Child args: ${args.join(' ')}`)
if (debugFile) {
deps.onDebug(`[bridge:session] Debug log: ${debugFile}`)
}
// Pipe all three streams: stdin for control, stdout for NDJSON parsing,
// stderr for error capture and diagnostics.
const child: ChildProcess = spawn(deps.execPath, args, {
cwd: dir,
stdio: ['pipe', 'pipe', 'pipe'],
env,
windowsHide: true,
})
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} pid=${child.pid}`,
)
const activities: SessionActivity[] = []
let currentActivity: SessionActivity | null = null
const lastStderr: string[] = []
let sigkillSent = false
let firstUserMessageSeen = false
// Buffer stderr for error diagnostics
if (child.stderr) {
const stderrRl = createInterface({ input: child.stderr })
stderrRl.on('line', line => {
// Forward stderr to bridge's stderr in verbose mode
if (deps.verbose) {
process.stderr.write(line + '\n')
}
// Ring buffer of last N lines
if (lastStderr.length >= MAX_STDERR_LINES) {
lastStderr.shift()
}
lastStderr.push(line)
})
}
// Parse NDJSON from child stdout
if (child.stdout) {
const rl = createInterface({ input: child.stdout })
rl.on('line', line => {
// Write raw NDJSON to transcript file
if (transcriptStream) {
transcriptStream.write(line + '\n')
}
// Log all messages flowing from the child CLI to the bridge
deps.onDebug(
`[bridge:ws] sessionId=${opts.sessionId} <<< ${debugTruncate(line)}`,
)
// In verbose mode, forward raw output to stderr
if (deps.verbose) {
process.stderr.write(line + '\n')
}
const extracted = extractActivities(
line,
opts.sessionId,
deps.onDebug,
)
for (const activity of extracted) {
// Maintain ring buffer
if (activities.length >= MAX_ACTIVITIES) {
activities.shift()
}
activities.push(activity)
currentActivity = activity
deps.onActivity?.(opts.sessionId, activity)
}
// Detect control_request and replayed user messages.
// extractActivities parses the same line but swallows parse errors
// and skips 'user' type — re-parse here is cheap (NDJSON lines are
// small) and keeps each path self-contained.
{
let parsed: unknown
try {
parsed = jsonParse(line)
} catch {
// Non-JSON line, skip detection
}
if (parsed && typeof parsed === 'object') {
const msg = parsed as Record<string, unknown>
if (msg.type === 'control_request') {
const request = msg.request as
| Record<string, unknown>
| undefined
if (
request?.subtype === 'can_use_tool' &&
deps.onPermissionRequest
) {
deps.onPermissionRequest(
opts.sessionId,
parsed as PermissionRequest,
opts.accessToken,
)
}
// interrupt is turn-level; the child handles it internally (print.ts)
} else if (
msg.type === 'user' &&
!firstUserMessageSeen &&
opts.onFirstUserMessage
) {
const text = extractUserMessageText(msg)
if (text) {
firstUserMessageSeen = true
opts.onFirstUserMessage(text)
}
}
}
}
})
}
const done = new Promise<SessionDoneStatus>(resolve => {
child.on('close', (code, signal) => {
// Close transcript stream on exit
if (transcriptStream) {
transcriptStream.end()
transcriptStream = null
}
if (signal === 'SIGTERM' || signal === 'SIGINT') {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} interrupted signal=${signal} pid=${child.pid}`,
)
resolve('interrupted')
} else if (code === 0) {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} completed exit_code=0 pid=${child.pid}`,
)
resolve('completed')
} else {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} failed exit_code=${code} pid=${child.pid}`,
)
resolve('failed')
}
})
child.on('error', err => {
deps.onDebug(
`[bridge:session] sessionId=${opts.sessionId} spawn error: ${err.message}`,
)
resolve('failed')
})
})
const handle: SessionHandle = {
sessionId: opts.sessionId,
done,
activities,
accessToken: opts.accessToken,
lastStderr,
get currentActivity(): SessionActivity | null {
return currentActivity
},
kill(): void {
if (!child.killed) {
deps.onDebug(
`[bridge:session] Sending SIGTERM to sessionId=${opts.sessionId} pid=${child.pid}`,
)
// On Windows, child.kill('SIGTERM') throws; use default signal.
if (process.platform === 'win32') {
child.kill()
} else {
child.kill('SIGTERM')
}
}
},
forceKill(): void {
// Use separate flag because child.killed is set when kill() is called,
// not when the process exits. We need to send SIGKILL even after SIGTERM.
if (!sigkillSent && child.pid) {
sigkillSent = true
deps.onDebug(
`[bridge:session] Sending SIGKILL to sessionId=${opts.sessionId} pid=${child.pid}`,
)
if (process.platform === 'win32') {
child.kill()
} else {
child.kill('SIGKILL')
}
}
},
writeStdin(data: string): void {
if (child.stdin && !child.stdin.destroyed) {
deps.onDebug(
`[bridge:ws] sessionId=${opts.sessionId} >>> ${debugTruncate(data)}`,
)
child.stdin.write(data)
}
},
updateAccessToken(token: string): void {
handle.accessToken = token
// Send the fresh token to the child process via stdin. The child's
// StructuredIO handles update_environment_variables messages by
// setting process.env directly, so getSessionIngressAuthToken()
// picks up the new token on the next refreshHeaders call.
handle.writeStdin(
jsonStringify({
type: 'update_environment_variables',
variables: { CLAUDE_CODE_SESSION_ACCESS_TOKEN: token },
}) + '\n',
)
deps.onDebug(
`[bridge:session] Sent token refresh via stdin for sessionId=${opts.sessionId}`,
)
},
}
return handle
},
}
}
export { extractActivities as _extractActivitiesForTesting }
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' };
@@ -0,0 +1,15 @@
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
const compact = {
type: 'local',
name: 'compact',
description:
'Clear conversation history but keep a summary in context. Optional: /compact [instructions for summarization]',
isEnabled: () => !isEnvTruthy(process.env.DISABLE_COMPACT),
supportsNonInteractive: true,
argumentHint: '<optional custom summarization instructions>',
load: () => import('./compact.js'),
} satisfies Command
export default compact
@@ -0,0 +1,8 @@
import type { Command } from '../../commands.js'
export default {
type: 'local-jsx',
name: 'diff',
description: 'View uncommitted changes and per-turn diffs',
load: () => import('./diff.js'),
} satisfies Command
File diff suppressed because one or more lines are too long
@@ -0,0 +1,13 @@
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
const installGitHubApp = {
type: 'local-jsx',
name: 'install-github-app',
description: 'Set up Claude GitHub Actions for a repository',
availability: ['claude-ai', 'console'],
isEnabled: () => !isEnvTruthy(process.env.DISABLE_INSTALL_GITHUB_APP_COMMAND),
load: () => import('./install-github-app.js'),
} satisfies Command
export default installGitHubApp
@@ -0,0 +1,14 @@
import type { Command } from '../../commands.js'
import { hasAnthropicApiKeyAuth } from '../../utils/auth.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
export default () =>
({
type: 'local-jsx',
name: 'login',
description: hasAnthropicApiKeyAuth()
? 'Switch Anthropic accounts'
: 'Sign in with your Anthropic account',
isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGIN_COMMAND),
load: () => import('./login.js'),
}) satisfies Command
@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const mobile = {
type: 'local-jsx',
name: 'mobile',
aliases: ['ios', 'android'],
description: 'Show QR code to download the Claude mobile app',
load: () => import('./mobile.js'),
} satisfies Command
export default mobile
@@ -0,0 +1,24 @@
import * as React from 'react';
import { Passes } from '../../components/Passes/Passes.js';
import { logEvent } from '../../services/analytics/index.js';
import { getCachedRemainingPasses } from '../../services/api/referral.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js';
export async function call(onDone: LocalJSXCommandOnDone): Promise<React.ReactNode> {
// Mark that user has visited /passes so we stop showing the upsell
const config = getGlobalConfig();
const isFirstVisit = !config.hasVisitedPasses;
if (isFirstVisit) {
const remaining = getCachedRemainingPasses();
saveGlobalConfig(current => ({
...current,
hasVisitedPasses: true,
passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining
}));
}
logEvent('tengu_guest_passes_visited', {
is_first_visit: isFirstVisit
});
return <Passes onDone={onDone} />;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIlBhc3NlcyIsImxvZ0V2ZW50IiwiZ2V0Q2FjaGVkUmVtYWluaW5nUGFzc2VzIiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiZ2V0R2xvYmFsQ29uZmlnIiwic2F2ZUdsb2JhbENvbmZpZyIsImNhbGwiLCJvbkRvbmUiLCJQcm9taXNlIiwiUmVhY3ROb2RlIiwiY29uZmlnIiwiaXNGaXJzdFZpc2l0IiwiaGFzVmlzaXRlZFBhc3NlcyIsInJlbWFpbmluZyIsImN1cnJlbnQiLCJwYXNzZXNMYXN0U2VlblJlbWFpbmluZyIsImlzX2ZpcnN0X3Zpc2l0Il0sInNvdXJjZXMiOlsicGFzc2VzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IFBhc3NlcyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvUGFzc2VzL1Bhc3Nlcy5qcydcbmltcG9ydCB7IGxvZ0V2ZW50IH0gZnJvbSAnLi4vLi4vc2VydmljZXMvYW5hbHl0aWNzL2luZGV4LmpzJ1xuaW1wb3J0IHsgZ2V0Q2FjaGVkUmVtYWluaW5nUGFzc2VzIH0gZnJvbSAnLi4vLi4vc2VydmljZXMvYXBpL3JlZmVycmFsLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuaW1wb3J0IHsgZ2V0R2xvYmFsQ29uZmlnLCBzYXZlR2xvYmFsQ29uZmlnIH0gZnJvbSAnLi4vLi4vdXRpbHMvY29uZmlnLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiBMb2NhbEpTWENvbW1hbmRPbkRvbmUsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICAvLyBNYXJrIHRoYXQgdXNlciBoYXMgdmlzaXRlZCAvcGFzc2VzIHNvIHdlIHN0b3Agc2hvd2luZyB0aGUgdXBzZWxsXG4gIGNvbnN0IGNvbmZpZyA9IGdldEdsb2JhbENvbmZpZygpXG4gIGNvbnN0IGlzRmlyc3RWaXNpdCA9ICFjb25maWcuaGFzVmlzaXRlZFBhc3Nlc1xuICBpZiAoaXNGaXJzdFZpc2l0KSB7XG4gICAgY29uc3QgcmVtYWluaW5nID0gZ2V0Q2FjaGVkUmVtYWluaW5nUGFzc2VzKClcbiAgICBzYXZlR2xvYmFsQ29uZmlnKGN1cnJlbnQgPT4gKHtcbiAgICAgIC4uLmN1cnJlbnQsXG4gICAgICBoYXNWaXNpdGVkUGFzc2VzOiB0cnVlLFxuICAgICAgcGFzc2VzTGFzdFNlZW5SZW1haW5pbmc6IHJlbWFpbmluZyA/PyBjdXJyZW50LnBhc3Nlc0xhc3RTZWVuUmVtYWluaW5nLFxuICAgIH0pKVxuICB9XG4gIGxvZ0V2ZW50KCd0ZW5ndV9ndWVzdF9wYXNzZXNfdmlzaXRlZCcsIHsgaXNfZmlyc3RfdmlzaXQ6IGlzRmlyc3RWaXNpdCB9KVxuICByZXR1cm4gPFBhc3NlcyBvbkRvbmU9e29uRG9uZX0gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixTQUFTQyxNQUFNLFFBQVEsbUNBQW1DO0FBQzFELFNBQVNDLFFBQVEsUUFBUSxtQ0FBbUM7QUFDNUQsU0FBU0Msd0JBQXdCLFFBQVEsZ0NBQWdDO0FBQ3pFLGNBQWNDLHFCQUFxQixRQUFRLHdCQUF3QjtBQUNuRSxTQUFTQyxlQUFlLEVBQUVDLGdCQUFnQixRQUFRLHVCQUF1QjtBQUV6RSxPQUFPLGVBQWVDLElBQUlBLENBQ3hCQyxNQUFNLEVBQUVKLHFCQUFxQixDQUM5QixFQUFFSyxPQUFPLENBQUNULEtBQUssQ0FBQ1UsU0FBUyxDQUFDLENBQUM7RUFDMUI7RUFDQSxNQUFNQyxNQUFNLEdBQUdOLGVBQWUsQ0FBQyxDQUFDO0VBQ2hDLE1BQU1PLFlBQVksR0FBRyxDQUFDRCxNQUFNLENBQUNFLGdCQUFnQjtFQUM3QyxJQUFJRCxZQUFZLEVBQUU7SUFDaEIsTUFBTUUsU0FBUyxHQUFHWCx3QkFBd0IsQ0FBQyxDQUFDO0lBQzVDRyxnQkFBZ0IsQ0FBQ1MsT0FBTyxLQUFLO01BQzNCLEdBQUdBLE9BQU87TUFDVkYsZ0JBQWdCLEVBQUUsSUFBSTtNQUN0QkcsdUJBQXVCLEVBQUVGLFNBQVMsSUFBSUMsT0FBTyxDQUFDQztJQUNoRCxDQUFDLENBQUMsQ0FBQztFQUNMO0VBQ0FkLFFBQVEsQ0FBQyw0QkFBNEIsRUFBRTtJQUFFZSxjQUFjLEVBQUVMO0VBQWEsQ0FBQyxDQUFDO0VBQ3hFLE9BQU8sQ0FBQyxNQUFNLENBQUMsTUFBTSxDQUFDLENBQUNKLE1BQU0sQ0FBQyxHQUFHO0FBQ25DIiwiaWdub3JlTGlzdCI6W119
File diff suppressed because one or more lines are too long
@@ -0,0 +1,34 @@
// @generated stub from scan-missing-imports
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
// bun build resolver 的占位符。
const __target = function noop() {}
const __handler: ProxyHandler<any> = {
get(_t, prop) {
if (prop === '__esModule') return true
if (prop === 'default') return new Proxy(__target, __handler)
if (prop === Symbol.toPrimitive) return () => undefined
if (prop === Symbol.iterator) return function* () {}
if (prop === Symbol.asyncIterator) return async function* () {}
if (prop === 'then') return undefined
return new Proxy(__target, __handler)
},
apply() {
return new Proxy(__target, __handler)
},
construct() {
return new Proxy(__target, __handler)
},
}
const stub: any = new Proxy(__target, __handler)
export default stub
export const __stubMissing = true
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
export const createCachedMCState = stub
export const isCachedMicrocompactEnabled = stub
export const isModelSupportedForCacheEditing = stub
export const getCachedMCConfig = stub
export const markToolsSentToAPI = stub
export const resetCachedMCState = stub
export const checkProtectedNamespace = stub
export const getCoordinatorUserContext = stub
@@ -0,0 +1,34 @@
// @generated stub from scan-missing-imports
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
// bun build resolver 的占位符。
const __target = function noop() {}
const __handler: ProxyHandler<any> = {
get(_t, prop) {
if (prop === '__esModule') return true
if (prop === 'default') return new Proxy(__target, __handler)
if (prop === Symbol.toPrimitive) return () => undefined
if (prop === Symbol.iterator) return function* () {}
if (prop === Symbol.asyncIterator) return async function* () {}
if (prop === 'then') return undefined
return new Proxy(__target, __handler)
},
apply() {
return new Proxy(__target, __handler)
},
construct() {
return new Proxy(__target, __handler)
},
}
const stub: any = new Proxy(__target, __handler)
export default stub
export const __stubMissing = true
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
export const createCachedMCState = stub
export const isCachedMicrocompactEnabled = stub
export const isModelSupportedForCacheEditing = stub
export const getCachedMCConfig = stub
export const markToolsSentToAPI = stub
export const resetCachedMCState = stub
export const checkProtectedNamespace = stub
export const getCoordinatorUserContext = stub
@@ -0,0 +1,67 @@
import { queryHaiku } from '../../services/api/claude.js'
import type { Message } from '../../types/message.js'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { safeParseJSON } from '../../utils/json.js'
import { extractTextContent } from '../../utils/messages.js'
import { extractConversationText } from '../../utils/sessionTitle.js'
import { asSystemPrompt } from '../../utils/systemPromptType.js'
export async function generateSessionName(
messages: Message[],
signal: AbortSignal,
): Promise<string | null> {
const conversationText = extractConversationText(messages)
if (!conversationText) {
return null
}
try {
const result = await queryHaiku({
systemPrompt: asSystemPrompt([
'Generate a short kebab-case name (2-4 words) that captures the main topic of this conversation. Use lowercase words separated by hyphens. Examples: "fix-login-bug", "add-auth-feature", "refactor-api-client", "debug-test-failures". Return JSON with a "name" field.',
]),
userPrompt: conversationText,
outputFormat: {
type: 'json_schema',
schema: {
type: 'object',
properties: {
name: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
},
signal,
options: {
querySource: 'rename_generate_name',
agents: [],
isNonInteractiveSession: false,
hasAppendSystemPrompt: false,
mcpTools: [],
},
})
const content = extractTextContent(result.message.content)
const response = safeParseJSON(content)
if (
response &&
typeof response === 'object' &&
'name' in response &&
typeof (response as { name: unknown }).name === 'string'
) {
return (response as { name: string }).name
}
return null
} catch (error) {
// Haiku timeout/rate-limit/network are expected operational failures —
// logForDebugging, not logError. Called automatically on every 3rd bridge
// message (initReplBridge.ts), so errors here would flood the error file.
logForDebugging(`generateSessionName failed: ${errorMessage(error)}`, {
level: 'error',
})
return null
}
}
@@ -0,0 +1,87 @@
import type { UUID } from 'crypto'
import { getSessionId } from '../../bootstrap/state.js'
import {
getBridgeBaseUrlOverride,
getBridgeTokenOverride,
} from '../../bridge/bridgeConfig.js'
import type { ToolUseContext } from '../../Tool.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import {
getTranscriptPath,
saveAgentName,
saveCustomTitle,
} from '../../utils/sessionStorage.js'
import { isTeammate } from '../../utils/teammate.js'
import { generateSessionName } from './generateSessionName.js'
export async function call(
onDone: LocalJSXCommandOnDone,
context: ToolUseContext & LocalJSXCommandContext,
args: string,
): Promise<null> {
// Prevent teammates from renaming - their names are set by team leader
if (isTeammate()) {
onDone(
'Cannot rename: This session is a swarm teammate. Teammate names are set by the team leader.',
{ display: 'system' },
)
return null
}
let newName: string
if (!args || args.trim() === '') {
const generated = await generateSessionName(
getMessagesAfterCompactBoundary(context.messages),
context.abortController.signal,
)
if (!generated) {
onDone(
'Could not generate a name: no conversation context yet. Usage: /rename <name>',
{ display: 'system' },
)
return null
}
newName = generated
} else {
newName = args.trim()
}
const sessionId = getSessionId() as UUID
const fullPath = getTranscriptPath()
// Always save the custom title (session name)
await saveCustomTitle(sessionId, newName, fullPath)
// Sync title to bridge session on claude.ai/code (best-effort, non-blocking).
// v2 env-less bridge stores cse_* in replBridgeSessionId —
// updateBridgeSessionTitle retags internally for the compat endpoint.
const appState = context.getAppState()
const bridgeSessionId = appState.replBridgeSessionId
if (bridgeSessionId) {
const tokenOverride = getBridgeTokenOverride()
void import('../../bridge/createSession.js').then(
({ updateBridgeSessionTitle }) =>
updateBridgeSessionTitle(bridgeSessionId, newName, {
baseUrl: getBridgeBaseUrlOverride(),
getAccessToken: tokenOverride ? () => tokenOverride : undefined,
}).catch(() => {}),
)
}
// Also persist as the session's agent name for prompt-bar display
await saveAgentName(sessionId, newName, fullPath)
context.setAppState(prev => ({
...prev,
standaloneAgentContext: {
...prev.standaloneAgentContext,
name: newName,
},
}))
onDone(`Session renamed to: ${newName}`, { display: 'system' })
return null
}
@@ -0,0 +1,8 @@
import * as React from 'react';
import type { LocalJSXCommandContext } from '../../commands.js';
import { SkillsMenu } from '../../components/skills/SkillsMenu.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
return <SkillsMenu onExit={onDone} commands={context.options.commands} />;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENvbnRleHQiLCJTa2lsbHNNZW51IiwiTG9jYWxKU1hDb21tYW5kT25Eb25lIiwiY2FsbCIsIm9uRG9uZSIsImNvbnRleHQiLCJQcm9taXNlIiwiUmVhY3ROb2RlIiwib3B0aW9ucyIsImNvbW1hbmRzIl0sInNvdXJjZXMiOlsic2tpbGxzLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB0eXBlIHsgTG9jYWxKU1hDb21tYW5kQ29udGV4dCB9IGZyb20gJy4uLy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgU2tpbGxzTWVudSB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvc2tpbGxzL1NraWxsc01lbnUuanMnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZE9uRG9uZSB9IGZyb20gJy4uLy4uL3R5cGVzL2NvbW1hbmQuanMnXG5cbmV4cG9ydCBhc3luYyBmdW5jdGlvbiBjYWxsKFxuICBvbkRvbmU6IExvY2FsSlNYQ29tbWFuZE9uRG9uZSxcbiAgY29udGV4dDogTG9jYWxKU1hDb21tYW5kQ29udGV4dCxcbik6IFByb21pc2U8UmVhY3QuUmVhY3ROb2RlPiB7XG4gIHJldHVybiA8U2tpbGxzTWVudSBvbkV4aXQ9e29uRG9uZX0gY29tbWFuZHM9e2NvbnRleHQub3B0aW9ucy5jb21tYW5kc30gLz5cbn1cbiJdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxLQUFLQSxLQUFLLE1BQU0sT0FBTztBQUM5QixjQUFjQyxzQkFBc0IsUUFBUSxtQkFBbUI7QUFDL0QsU0FBU0MsVUFBVSxRQUFRLHVDQUF1QztBQUNsRSxjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFFbkUsT0FBTyxlQUFlQyxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFRixxQkFBcUIsRUFDN0JHLE9BQU8sRUFBRUwsc0JBQXNCLENBQ2hDLEVBQUVNLE9BQU8sQ0FBQ1AsS0FBSyxDQUFDUSxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMsVUFBVSxDQUFDLE1BQU0sQ0FBQyxDQUFDSCxNQUFNLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQ0MsT0FBTyxDQUFDRyxPQUFPLENBQUNDLFFBQVEsQ0FBQyxHQUFHO0FBQzNFIiwiaWdub3JlTGlzdCI6W119
@@ -0,0 +1,24 @@
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/index.mjs';
import type { Command } from '../commands.js';
import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js';
const statusline = {
type: 'prompt',
description: "Set up Claude Code's status line UI",
contentLength: 0,
// Dynamic content
aliases: [],
name: 'statusline',
progressMessage: 'setting up statusLine',
allowedTools: [AGENT_TOOL_NAME, 'Read(~/**)', 'Edit(~/.claude/settings.json)'],
source: 'builtin',
disableNonInteractive: true,
async getPromptForCommand(args): Promise<ContentBlockParam[]> {
const prompt = args.trim() || 'Configure my statusLine from my shell PS1 configuration';
return [{
type: 'text',
text: `Create an ${AGENT_TOOL_NAME} with subagent_type "statusline-setup" and the prompt "${prompt}"`
}];
}
} satisfies Command;
export default statusline;
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJDb250ZW50QmxvY2tQYXJhbSIsIkNvbW1hbmQiLCJBR0VOVF9UT09MX05BTUUiLCJzdGF0dXNsaW5lIiwidHlwZSIsImRlc2NyaXB0aW9uIiwiY29udGVudExlbmd0aCIsImFsaWFzZXMiLCJuYW1lIiwicHJvZ3Jlc3NNZXNzYWdlIiwiYWxsb3dlZFRvb2xzIiwic291cmNlIiwiZGlzYWJsZU5vbkludGVyYWN0aXZlIiwiZ2V0UHJvbXB0Rm9yQ29tbWFuZCIsImFyZ3MiLCJQcm9taXNlIiwicHJvbXB0IiwidHJpbSIsInRleHQiXSwic291cmNlcyI6WyJzdGF0dXNsaW5lLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgdHlwZSB7IENvbnRlbnRCbG9ja1BhcmFtIH0gZnJvbSAnQGFudGhyb3BpYy1haS9zZGsvcmVzb3VyY2VzL2luZGV4Lm1qcydcbmltcG9ydCB0eXBlIHsgQ29tbWFuZCB9IGZyb20gJy4uL2NvbW1hbmRzLmpzJ1xuaW1wb3J0IHsgQUdFTlRfVE9PTF9OQU1FIH0gZnJvbSAnLi4vdG9vbHMvQWdlbnRUb29sL2NvbnN0YW50cy5qcydcblxuY29uc3Qgc3RhdHVzbGluZSA9IHtcbiAgdHlwZTogJ3Byb21wdCcsXG4gIGRlc2NyaXB0aW9uOiBcIlNldCB1cCBDbGF1ZGUgQ29kZSdzIHN0YXR1cyBsaW5lIFVJXCIsXG4gIGNvbnRlbnRMZW5ndGg6IDAsIC8vIER5bmFtaWMgY29udGVudFxuICBhbGlhc2VzOiBbXSxcbiAgbmFtZTogJ3N0YXR1c2xpbmUnLFxuICBwcm9ncmVzc01lc3NhZ2U6ICdzZXR0aW5nIHVwIHN0YXR1c0xpbmUnLFxuICBhbGxvd2VkVG9vbHM6IFtcbiAgICBBR0VOVF9UT09MX05BTUUsXG4gICAgJ1JlYWQofi8qKiknLFxuICAgICdFZGl0KH4vLmNsYXVkZS9zZXR0aW5ncy5qc29uKScsXG4gIF0sXG4gIHNvdXJjZTogJ2J1aWx0aW4nLFxuICBkaXNhYmxlTm9uSW50ZXJhY3RpdmU6IHRydWUsXG4gIGFzeW5jIGdldFByb21wdEZvckNvbW1hbmQoYXJncyk6IFByb21pc2U8Q29udGVudEJsb2NrUGFyYW1bXT4ge1xuICAgIGNvbnN0IHByb21wdCA9XG4gICAgICBhcmdzLnRyaW0oKSB8fCAnQ29uZmlndXJlIG15IHN0YXR1c0xpbmUgZnJvbSBteSBzaGVsbCBQUzEgY29uZmlndXJhdGlvbidcbiAgICByZXR1cm4gW1xuICAgICAge1xuICAgICAgICB0eXBlOiAndGV4dCcsXG4gICAgICAgIHRleHQ6IGBDcmVhdGUgYW4gJHtBR0VOVF9UT09MX05BTUV9IHdpdGggc3ViYWdlbnRfdHlwZSBcInN0YXR1c2xpbmUtc2V0dXBcIiBhbmQgdGhlIHByb21wdCBcIiR7cHJvbXB0fVwiYCxcbiAgICAgIH0sXG4gICAgXVxuICB9LFxufSBzYXRpc2ZpZXMgQ29tbWFuZFxuXG5leHBvcnQgZGVmYXVsdCBzdGF0dXNsaW5lXG4iXSwibWFwcGluZ3MiOiJBQUFBLGNBQWNBLGlCQUFpQixRQUFRLHVDQUF1QztBQUM5RSxjQUFjQyxPQUFPLFFBQVEsZ0JBQWdCO0FBQzdDLFNBQVNDLGVBQWUsUUFBUSxpQ0FBaUM7QUFFakUsTUFBTUMsVUFBVSxHQUFHO0VBQ2pCQyxJQUFJLEVBQUUsUUFBUTtFQUNkQyxXQUFXLEVBQUUscUNBQXFDO0VBQ2xEQyxhQUFhLEVBQUUsQ0FBQztFQUFFO0VBQ2xCQyxPQUFPLEVBQUUsRUFBRTtFQUNYQyxJQUFJLEVBQUUsWUFBWTtFQUNsQkMsZUFBZSxFQUFFLHVCQUF1QjtFQUN4Q0MsWUFBWSxFQUFFLENBQ1pSLGVBQWUsRUFDZixZQUFZLEVBQ1osK0JBQStCLENBQ2hDO0VBQ0RTLE1BQU0sRUFBRSxTQUFTO0VBQ2pCQyxxQkFBcUIsRUFBRSxJQUFJO0VBQzNCLE1BQU1DLG1CQUFtQkEsQ0FBQ0MsSUFBSSxDQUFDLEVBQUVDLE9BQU8sQ0FBQ2YsaUJBQWlCLEVBQUUsQ0FBQyxDQUFDO0lBQzVELE1BQU1nQixNQUFNLEdBQ1ZGLElBQUksQ0FBQ0csSUFBSSxDQUFDLENBQUMsSUFBSSx5REFBeUQ7SUFDMUUsT0FBTyxDQUNMO01BQ0ViLElBQUksRUFBRSxNQUFNO01BQ1pjLElBQUksRUFBRSxhQUFhaEIsZUFBZSwwREFBMERjLE1BQU07SUFDcEcsQ0FBQyxDQUNGO0VBQ0g7QUFDRixDQUFDLFdBQVdmLE9BQU87QUFFbkIsZUFBZUUsVUFBVSIsImlnbm9yZUxpc3QiOltdfQ==
@@ -0,0 +1,34 @@
// @generated stub from scan-missing-imports
// 该文件自动生成,对应 ant-internal 的 feature() gated 模块。
// 所有外部 build 的代码路径在 DCE 后都不会真的执行这里的代码,这只是
// bun build resolver 的占位符。
const __target = function noop() {}
const __handler: ProxyHandler<any> = {
get(_t, prop) {
if (prop === '__esModule') return true
if (prop === 'default') return new Proxy(__target, __handler)
if (prop === Symbol.toPrimitive) return () => undefined
if (prop === Symbol.iterator) return function* () {}
if (prop === Symbol.asyncIterator) return async function* () {}
if (prop === 'then') return undefined
return new Proxy(__target, __handler)
},
apply() {
return new Proxy(__target, __handler)
},
construct() {
return new Proxy(__target, __handler)
},
}
const stub: any = new Proxy(__target, __handler)
export default stub
export const __stubMissing = true
// 兼容常见的命名导出 —— 没列在这里的也会通过 default Proxy 兜底
export const createCachedMCState = stub
export const isCachedMicrocompactEnabled = stub
export const isModelSupportedForCacheEditing = stub
export const getCachedMCConfig = stub
export const markToolsSentToAPI = stub
export const resetCachedMCState = stub
export const checkProtectedNamespace = stub
export const getCoordinatorUserContext = stub
@@ -0,0 +1,8 @@
import * as React from 'react';
import type { LocalJSXCommandContext } from '../../commands.js';
import { BackgroundTasksDialog } from '../../components/tasks/BackgroundTasksDialog.js';
import type { LocalJSXCommandOnDone } from '../../types/command.js';
export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise<React.ReactNode> {
return <BackgroundTasksDialog toolUseContext={context} onDone={onDone} />;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkxvY2FsSlNYQ29tbWFuZENvbnRleHQiLCJCYWNrZ3JvdW5kVGFza3NEaWFsb2ciLCJMb2NhbEpTWENvbW1hbmRPbkRvbmUiLCJjYWxsIiwib25Eb25lIiwiY29udGV4dCIsIlByb21pc2UiLCJSZWFjdE5vZGUiXSwic291cmNlcyI6WyJ0YXNrcy50c3giXSwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0ICogYXMgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgdHlwZSB7IExvY2FsSlNYQ29tbWFuZENvbnRleHQgfSBmcm9tICcuLi8uLi9jb21tYW5kcy5qcydcbmltcG9ydCB7IEJhY2tncm91bmRUYXNrc0RpYWxvZyB9IGZyb20gJy4uLy4uL2NvbXBvbmVudHMvdGFza3MvQmFja2dyb3VuZFRhc2tzRGlhbG9nLmpzJ1xuaW1wb3J0IHR5cGUgeyBMb2NhbEpTWENvbW1hbmRPbkRvbmUgfSBmcm9tICcuLi8uLi90eXBlcy9jb21tYW5kLmpzJ1xuXG5leHBvcnQgYXN5bmMgZnVuY3Rpb24gY2FsbChcbiAgb25Eb25lOiBMb2NhbEpTWENvbW1hbmRPbkRvbmUsXG4gIGNvbnRleHQ6IExvY2FsSlNYQ29tbWFuZENvbnRleHQsXG4pOiBQcm9taXNlPFJlYWN0LlJlYWN0Tm9kZT4ge1xuICByZXR1cm4gPEJhY2tncm91bmRUYXNrc0RpYWxvZyB0b29sVXNlQ29udGV4dD17Y29udGV4dH0gb25Eb25lPXtvbkRvbmV9IC8+XG59XG4iXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsY0FBY0Msc0JBQXNCLFFBQVEsbUJBQW1CO0FBQy9ELFNBQVNDLHFCQUFxQixRQUFRLGlEQUFpRDtBQUN2RixjQUFjQyxxQkFBcUIsUUFBUSx3QkFBd0I7QUFFbkUsT0FBTyxlQUFlQyxJQUFJQSxDQUN4QkMsTUFBTSxFQUFFRixxQkFBcUIsRUFDN0JHLE9BQU8sRUFBRUwsc0JBQXNCLENBQ2hDLEVBQUVNLE9BQU8sQ0FBQ1AsS0FBSyxDQUFDUSxTQUFTLENBQUMsQ0FBQztFQUMxQixPQUFPLENBQUMscUJBQXFCLENBQUMsY0FBYyxDQUFDLENBQUNGLE9BQU8sQ0FBQyxDQUFDLE1BQU0sQ0FBQyxDQUFDRCxNQUFNLENBQUMsR0FBRztBQUMzRSIsImlnbm9yZUxpc3QiOltdfQ==
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,20 @@
import type { Command } from '../../commands.js'
import {
isVoiceGrowthBookEnabled,
isVoiceModeEnabled,
} from '../../voice/voiceModeEnabled.js'
const voice = {
type: 'local',
name: 'voice',
description: 'Toggle voice mode',
availability: ['claude-ai'],
isEnabled: () => isVoiceGrowthBookEnabled(),
get isHidden() {
return !isVoiceModeEnabled()
},
supportsNonInteractive: false,
load: () => import('./voice.js'),
} satisfies Command
export default voice
File diff suppressed because one or more lines are too long
@@ -0,0 +1,653 @@
import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import { isDeepStrictEqual } from 'util'
import OptionMap from './option-map.js'
import type { OptionWithDescription } from './select.js'
type State<T> = {
/**
* Map where key is option's value and value is option's index.
*/
optionMap: OptionMap<T>
/**
* Number of visible options.
*/
visibleOptionCount: number
/**
* Value of the currently focused option.
*/
focusedValue: T | undefined
/**
* Index of the first visible option.
*/
visibleFromIndex: number
/**
* Index of the last visible option.
*/
visibleToIndex: number
}
type Action<T> =
| FocusNextOptionAction
| FocusPreviousOptionAction
| FocusNextPageAction
| FocusPreviousPageAction
| SetFocusAction<T>
| ResetAction<T>
type SetFocusAction<T> = {
type: 'set-focus'
value: T
}
type FocusNextOptionAction = {
type: 'focus-next-option'
}
type FocusPreviousOptionAction = {
type: 'focus-previous-option'
}
type FocusNextPageAction = {
type: 'focus-next-page'
}
type FocusPreviousPageAction = {
type: 'focus-previous-page'
}
type ResetAction<T> = {
type: 'reset'
state: State<T>
}
const reducer = <T>(state: State<T>, action: Action<T>): State<T> => {
switch (action.type) {
case 'focus-next-option': {
if (state.focusedValue === undefined) {
return state
}
const item = state.optionMap.get(state.focusedValue)
if (!item) {
return state
}
// Wrap to first item if at the end
const next = item.next || state.optionMap.first
if (!next) {
return state
}
// When wrapping to first, reset viewport to start
if (!item.next && next === state.optionMap.first) {
return {
...state,
focusedValue: next.value,
visibleFromIndex: 0,
visibleToIndex: state.visibleOptionCount,
}
}
const needsToScroll = next.index >= state.visibleToIndex
if (!needsToScroll) {
return {
...state,
focusedValue: next.value,
}
}
const nextVisibleToIndex = Math.min(
state.optionMap.size,
state.visibleToIndex + 1,
)
const nextVisibleFromIndex = nextVisibleToIndex - state.visibleOptionCount
return {
...state,
focusedValue: next.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
case 'focus-previous-option': {
if (state.focusedValue === undefined) {
return state
}
const item = state.optionMap.get(state.focusedValue)
if (!item) {
return state
}
// Wrap to last item if at the beginning
const previous = item.previous || state.optionMap.last
if (!previous) {
return state
}
// When wrapping to last, reset viewport to end
if (!item.previous && previous === state.optionMap.last) {
const nextVisibleToIndex = state.optionMap.size
const nextVisibleFromIndex = Math.max(
0,
nextVisibleToIndex - state.visibleOptionCount,
)
return {
...state,
focusedValue: previous.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
const needsToScroll = previous.index <= state.visibleFromIndex
if (!needsToScroll) {
return {
...state,
focusedValue: previous.value,
}
}
const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1)
const nextVisibleToIndex = nextVisibleFromIndex + state.visibleOptionCount
return {
...state,
focusedValue: previous.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
case 'focus-next-page': {
if (state.focusedValue === undefined) {
return state
}
const item = state.optionMap.get(state.focusedValue)
if (!item) {
return state
}
// Move by a full page (visibleOptionCount items)
const targetIndex = Math.min(
state.optionMap.size - 1,
item.index + state.visibleOptionCount,
)
// Find the item at the target index
let targetItem = state.optionMap.first
while (targetItem && targetItem.index < targetIndex) {
if (targetItem.next) {
targetItem = targetItem.next
} else {
break
}
}
if (!targetItem) {
return state
}
// Update the visible range to include the new focused item
const nextVisibleToIndex = Math.min(
state.optionMap.size,
targetItem.index + 1,
)
const nextVisibleFromIndex = Math.max(
0,
nextVisibleToIndex - state.visibleOptionCount,
)
return {
...state,
focusedValue: targetItem.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
case 'focus-previous-page': {
if (state.focusedValue === undefined) {
return state
}
const item = state.optionMap.get(state.focusedValue)
if (!item) {
return state
}
// Move by a full page (visibleOptionCount items)
const targetIndex = Math.max(0, item.index - state.visibleOptionCount)
// Find the item at the target index
let targetItem = state.optionMap.first
while (targetItem && targetItem.index < targetIndex) {
if (targetItem.next) {
targetItem = targetItem.next
} else {
break
}
}
if (!targetItem) {
return state
}
// Update the visible range to include the new focused item
const nextVisibleFromIndex = Math.max(0, targetItem.index)
const nextVisibleToIndex = Math.min(
state.optionMap.size,
nextVisibleFromIndex + state.visibleOptionCount,
)
return {
...state,
focusedValue: targetItem.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
case 'reset': {
return action.state
}
case 'set-focus': {
// Early return if already focused on this value
if (state.focusedValue === action.value) {
return state
}
const item = state.optionMap.get(action.value)
if (!item) {
return state
}
// Check if the item is already in view
if (
item.index >= state.visibleFromIndex &&
item.index < state.visibleToIndex
) {
// Already visible, just update focus
return {
...state,
focusedValue: action.value,
}
}
// Need to scroll to make the item visible
// Scroll as little as possible - put item at edge of viewport
let nextVisibleFromIndex: number
let nextVisibleToIndex: number
if (item.index < state.visibleFromIndex) {
// Item is above viewport - scroll up to put it at the top
nextVisibleFromIndex = item.index
nextVisibleToIndex = Math.min(
state.optionMap.size,
nextVisibleFromIndex + state.visibleOptionCount,
)
} else {
// Item is below viewport - scroll down to put it at the bottom
nextVisibleToIndex = Math.min(state.optionMap.size, item.index + 1)
nextVisibleFromIndex = Math.max(
0,
nextVisibleToIndex - state.visibleOptionCount,
)
}
return {
...state,
focusedValue: action.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
}
}
}
}
export type UseSelectNavigationProps<T> = {
/**
* Number of items to display.
*
* @default 5
*/
visibleOptionCount?: number
/**
* Options.
*/
options: OptionWithDescription<T>[]
/**
* Initially focused option's value.
*/
initialFocusValue?: T
/**
* Callback for focusing an option.
*/
onFocus?: (value: T) => void
/**
* Value to focus
*/
focusValue?: T
}
export type SelectNavigation<T> = {
/**
* Value of the currently focused option.
*/
focusedValue: T | undefined
/**
* 1-based index of the focused option in the full list.
* Returns 0 if no option is focused.
*/
focusedIndex: number
/**
* Index of the first visible option.
*/
visibleFromIndex: number
/**
* Index of the last visible option.
*/
visibleToIndex: number
/**
* All options.
*/
options: OptionWithDescription<T>[]
/**
* Visible options.
*/
visibleOptions: Array<OptionWithDescription<T> & { index: number }>
/**
* Whether the focused option is an input type.
*/
isInInput: boolean
/**
* Focus next option and scroll the list down, if needed.
*/
focusNextOption: () => void
/**
* Focus previous option and scroll the list up, if needed.
*/
focusPreviousOption: () => void
/**
* Focus next page and scroll the list down by a page.
*/
focusNextPage: () => void
/**
* Focus previous page and scroll the list up by a page.
*/
focusPreviousPage: () => void
/**
* Focus a specific option by value.
*/
focusOption: (value: T | undefined) => void
}
const createDefaultState = <T>({
visibleOptionCount: customVisibleOptionCount,
options,
initialFocusValue,
currentViewport,
}: Pick<UseSelectNavigationProps<T>, 'visibleOptionCount' | 'options'> & {
initialFocusValue?: T
currentViewport?: { visibleFromIndex: number; visibleToIndex: number }
}): State<T> => {
const visibleOptionCount =
typeof customVisibleOptionCount === 'number'
? Math.min(customVisibleOptionCount, options.length)
: options.length
const optionMap = new OptionMap<T>(options)
const focusedItem =
initialFocusValue !== undefined && optionMap.get(initialFocusValue)
const focusedValue = focusedItem ? initialFocusValue : optionMap.first?.value
let visibleFromIndex = 0
let visibleToIndex = visibleOptionCount
// When there's a valid focused item, adjust viewport to show it
if (focusedItem) {
const focusedIndex = focusedItem.index
if (currentViewport) {
// If focused item is already in the current viewport range, try to preserve it
if (
focusedIndex >= currentViewport.visibleFromIndex &&
focusedIndex < currentViewport.visibleToIndex
) {
// Keep the same viewport if it's valid
visibleFromIndex = currentViewport.visibleFromIndex
visibleToIndex = Math.min(
optionMap.size,
currentViewport.visibleToIndex,
)
} else {
// Need to adjust viewport to show focused item
// Use minimal scrolling - put item at edge of viewport
if (focusedIndex < currentViewport.visibleFromIndex) {
// Item is above current viewport - scroll up to put it at the top
visibleFromIndex = focusedIndex
visibleToIndex = Math.min(
optionMap.size,
visibleFromIndex + visibleOptionCount,
)
} else {
// Item is below current viewport - scroll down to put it at the bottom
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
}
}
} else if (focusedIndex >= visibleOptionCount) {
// No current viewport but focused item is outside default viewport
// Scroll to show the focused item at the bottom of the viewport
visibleToIndex = Math.min(optionMap.size, focusedIndex + 1)
visibleFromIndex = Math.max(0, visibleToIndex - visibleOptionCount)
}
// Ensure viewport bounds are valid
visibleFromIndex = Math.max(
0,
Math.min(visibleFromIndex, optionMap.size - 1),
)
visibleToIndex = Math.min(
optionMap.size,
Math.max(visibleOptionCount, visibleToIndex),
)
}
return {
optionMap,
visibleOptionCount,
focusedValue,
visibleFromIndex,
visibleToIndex,
}
}
export function useSelectNavigation<T>({
visibleOptionCount = 5,
options,
initialFocusValue,
onFocus,
focusValue,
}: UseSelectNavigationProps<T>): SelectNavigation<T> {
const [state, dispatch] = useReducer(
reducer<T>,
{
visibleOptionCount,
options,
initialFocusValue: focusValue || initialFocusValue,
} as Parameters<typeof createDefaultState<T>>[0],
createDefaultState<T>,
)
// Store onFocus in a ref to avoid re-running useEffect when callback changes
const onFocusRef = useRef(onFocus)
onFocusRef.current = onFocus
const [lastOptions, setLastOptions] = useState(options)
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
dispatch({
type: 'reset',
state: createDefaultState({
visibleOptionCount,
options,
initialFocusValue:
focusValue ?? state.focusedValue ?? initialFocusValue,
currentViewport: {
visibleFromIndex: state.visibleFromIndex,
visibleToIndex: state.visibleToIndex,
},
}),
})
setLastOptions(options)
}
const focusNextOption = useCallback(() => {
dispatch({
type: 'focus-next-option',
})
}, [])
const focusPreviousOption = useCallback(() => {
dispatch({
type: 'focus-previous-option',
})
}, [])
const focusNextPage = useCallback(() => {
dispatch({
type: 'focus-next-page',
})
}, [])
const focusPreviousPage = useCallback(() => {
dispatch({
type: 'focus-previous-page',
})
}, [])
const focusOption = useCallback((value: T | undefined) => {
if (value !== undefined) {
dispatch({
type: 'set-focus',
value,
})
}
}, [])
const visibleOptions = useMemo(() => {
return options
.map((option, index) => ({
...option,
index,
}))
.slice(state.visibleFromIndex, state.visibleToIndex)
}, [options, state.visibleFromIndex, state.visibleToIndex])
// Validate that focusedValue exists in current options.
// This handles the case where options change during render but the reset
// action hasn't been processed yet - without this, the cursor would disappear
// because focusedValue points to an option that no longer exists.
const validatedFocusedValue = useMemo(() => {
if (state.focusedValue === undefined) {
return undefined
}
const exists = options.some(opt => opt.value === state.focusedValue)
if (exists) {
return state.focusedValue
}
// Fall back to first option if focused value doesn't exist
return options[0]?.value
}, [state.focusedValue, options])
const isInInput = useMemo(() => {
const focusedOption = options.find(
opt => opt.value === validatedFocusedValue,
)
return focusedOption?.type === 'input'
}, [validatedFocusedValue, options])
// Call onFocus with the validated value (what's actually displayed),
// not the internal state value which may be stale if options changed.
// Use ref to avoid re-running when callback reference changes.
useEffect(() => {
if (validatedFocusedValue !== undefined) {
onFocusRef.current?.(validatedFocusedValue)
}
}, [validatedFocusedValue])
// Allow parent to programmatically set focus via focusValue prop
useEffect(() => {
if (focusValue !== undefined) {
dispatch({
type: 'set-focus',
value: focusValue,
})
}
}, [focusValue])
// Compute 1-based focused index for scroll position display
const focusedIndex = useMemo(() => {
if (validatedFocusedValue === undefined) {
return 0
}
const index = options.findIndex(opt => opt.value === validatedFocusedValue)
return index >= 0 ? index + 1 : 0
}, [validatedFocusedValue, options])
return {
focusedValue: validatedFocusedValue,
focusedIndex,
visibleFromIndex: state.visibleFromIndex,
visibleToIndex: state.visibleToIndex,
visibleOptions,
isInInput: isInInput ?? false,
focusNextOption,
focusPreviousOption,
focusNextPage,
focusPreviousPage,
focusOption,
options,
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
export const TEAMMATE_SELECT_HINT = 'shift + ↑/↓ to select'
@@ -0,0 +1,31 @@
import { useMemo } from 'react'
import { stringWidth } from '../../ink/stringWidth.js'
import { type DOMElement, useAnimationFrame } from '../../ink.js'
import type { SpinnerMode } from './types.js'
export function useShimmerAnimation(
mode: SpinnerMode,
message: string,
isStalled: boolean,
): [ref: (element: DOMElement | null) => void, glimmerIndex: number] {
const glimmerSpeed = mode === 'requesting' ? 50 : 200
// Pass null when stalled to unsubscribe from the clock — otherwise the
// setInterval keeps firing at 20fps even when the shimmer isn't visible.
// Notably, if the caller never attaches `ref` (e.g. conditional JSX),
// useTerminalViewport stays at its initial isVisible:true and the
// viewport-pause never kicks in, so this is the only stop mechanism.
const [ref, time] = useAnimationFrame(isStalled ? null : glimmerSpeed)
const messageWidth = useMemo(() => stringWidth(message), [message])
if (isStalled) {
return [ref, -100]
}
const cyclePosition = Math.floor(time / glimmerSpeed)
const cycleLength = messageWidth + 20
if (mode === 'requesting') {
return [ref, (cyclePosition % cycleLength) - 10]
}
return [ref, messageWidth + 10 - (cyclePosition % cycleLength)]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,27 @@
import type { SettingSource } from 'src/utils/settings/constants.js'
import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'
export const AGENT_PATHS = {
FOLDER_NAME: '.claude',
AGENTS_DIR: 'agents',
} as const
// Base types for common patterns
type WithPreviousMode = { previousMode: ModeState }
type WithAgent = { agent: AgentDefinition }
// Simplified state type using intersection types
export type ModeState =
| { mode: 'main-menu' }
| { mode: 'list-agents'; source: SettingSource | 'all' | 'built-in' }
| ({ mode: 'agent-menu' } & WithAgent & WithPreviousMode)
| ({ mode: 'view-agent' } & WithAgent & WithPreviousMode)
| { mode: 'create-agent' }
| ({ mode: 'edit-agent' } & WithAgent & WithPreviousMode)
| ({ mode: 'delete-confirm' } & WithAgent & WithPreviousMode)
export type AgentValidationResult = {
isValid: boolean
warnings: string[]
errors: string[]
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,9 @@
export { MCPAgentServerMenu } from './MCPAgentServerMenu.js'
export { MCPListPanel } from './MCPListPanel.js'
export { MCPReconnect } from './MCPReconnect.js'
export { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js'
export { MCPSettings } from './MCPSettings.js'
export { MCPStdioServerMenu } from './MCPStdioServerMenu.js'
export { MCPToolDetailView } from './MCPToolDetailView.js'
export { MCPToolListView } from './MCPToolListView.js'
export type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js'
@@ -0,0 +1,31 @@
import { c as _c } from "react/compiler-runtime";
import React from 'react';
import { Box, Text } from '../../ink.js';
type Props = {
addMargin: boolean;
};
export function AssistantRedactedThinkingMessage(t0) {
const $ = _c(3);
const {
addMargin: t1
} = t0;
const addMargin = t1 === undefined ? false : t1;
const t2 = addMargin ? 1 : 0;
let t3;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t3 = <Text dimColor={true} italic={true}>✻ Thinking…</Text>;
$[0] = t3;
} else {
t3 = $[0];
}
let t4;
if ($[1] !== t2) {
t4 = <Box marginTop={t2}>{t3}</Box>;
$[1] = t2;
$[2] = t4;
} else {
t4 = $[2];
}
return t4;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsIkJveCIsIlRleHQiLCJQcm9wcyIsImFkZE1hcmdpbiIsIkFzc2lzdGFudFJlZGFjdGVkVGhpbmtpbmdNZXNzYWdlIiwidDAiLCIkIiwiX2MiLCJ0MSIsInVuZGVmaW5lZCIsInQyIiwidDMiLCJTeW1ib2wiLCJmb3IiLCJ0NCJdLCJzb3VyY2VzIjpbIkFzc2lzdGFudFJlZGFjdGVkVGhpbmtpbmdNZXNzYWdlLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgUmVhY3QgZnJvbSAncmVhY3QnXG5pbXBvcnQgeyBCb3gsIFRleHQgfSBmcm9tICcuLi8uLi9pbmsuanMnXG5cbnR5cGUgUHJvcHMgPSB7XG4gIGFkZE1hcmdpbjogYm9vbGVhblxufVxuXG5leHBvcnQgZnVuY3Rpb24gQXNzaXN0YW50UmVkYWN0ZWRUaGlua2luZ01lc3NhZ2Uoe1xuICBhZGRNYXJnaW4gPSBmYWxzZSxcbn06IFByb3BzKTogUmVhY3QuUmVhY3ROb2RlIHtcbiAgcmV0dXJuIChcbiAgICA8Qm94IG1hcmdpblRvcD17YWRkTWFyZ2luID8gMSA6IDB9PlxuICAgICAgPFRleHQgZGltQ29sb3IgaXRhbGljPlxuICAgICAgICDinLsgVGhpbmtpbmfigKZcbiAgICAgIDwvVGV4dD5cbiAgICA8L0JveD5cbiAgKVxufVxuIl0sIm1hcHBpbmdzIjoiO0FBQUEsT0FBT0EsS0FBSyxNQUFNLE9BQU87QUFDekIsU0FBU0MsR0FBRyxFQUFFQyxJQUFJLFFBQVEsY0FBYztBQUV4QyxLQUFLQyxLQUFLLEdBQUc7RUFDWEMsU0FBUyxFQUFFLE9BQU87QUFDcEIsQ0FBQztBQUVELE9BQU8sU0FBQUMsaUNBQUFDLEVBQUE7RUFBQSxNQUFBQyxDQUFBLEdBQUFDLEVBQUE7RUFBMEM7SUFBQUosU0FBQSxFQUFBSztFQUFBLElBQUFILEVBRXpDO0VBRE4sTUFBQUYsU0FBQSxHQUFBSyxFQUFpQixLQUFqQkMsU0FBaUIsR0FBakIsS0FBaUIsR0FBakJELEVBQWlCO0VBR0MsTUFBQUUsRUFBQSxHQUFBUCxTQUFTLEdBQVQsQ0FBaUIsR0FBakIsQ0FBaUI7RUFBQSxJQUFBUSxFQUFBO0VBQUEsSUFBQUwsQ0FBQSxRQUFBTSxNQUFBLENBQUFDLEdBQUE7SUFDL0JGLEVBQUEsSUFBQyxJQUFJLENBQUMsUUFBUSxDQUFSLEtBQU8sQ0FBQyxDQUFDLE1BQU0sQ0FBTixLQUFLLENBQUMsQ0FBQyxXQUV0QixFQUZDLElBQUksQ0FFRTtJQUFBTCxDQUFBLE1BQUFLLEVBQUE7RUFBQTtJQUFBQSxFQUFBLEdBQUFMLENBQUE7RUFBQTtFQUFBLElBQUFRLEVBQUE7RUFBQSxJQUFBUixDQUFBLFFBQUFJLEVBQUE7SUFIVEksRUFBQSxJQUFDLEdBQUcsQ0FBWSxTQUFpQixDQUFqQixDQUFBSixFQUFnQixDQUFDLENBQy9CLENBQUFDLEVBRU0sQ0FDUixFQUpDLEdBQUcsQ0FJRTtJQUFBTCxDQUFBLE1BQUFJLEVBQUE7SUFBQUosQ0FBQSxNQUFBUSxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBUixDQUFBO0VBQUE7RUFBQSxPQUpOUSxFQUlNO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,212 @@
import { useCallback, useMemo, useState } from 'react'
import { useAppState } from 'src/state/AppState.js'
import { useKeybindings } from '../../../keybindings/useKeybinding.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../../services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
import type { CompletionType } from '../../../utils/unaryLogging.js'
import type { ToolUseConfirm } from '../PermissionRequest.js'
import {
type FileOperationType,
getFilePermissionOptions,
type PermissionOption,
type PermissionOptionWithLabel,
} from './permissionOptions.js'
import {
PERMISSION_HANDLERS,
type PermissionHandlerParams,
} from './usePermissionHandler.js'
export interface ToolInput {
[key: string]: unknown
}
export type UseFilePermissionDialogProps<T extends ToolInput> = {
filePath: string
completionType: CompletionType
languageName: string | Promise<string>
toolUseConfirm: ToolUseConfirm
onDone: () => void
onReject: () => void
parseInput: (input: unknown) => T
operationType?: FileOperationType
}
export type UseFilePermissionDialogResult<T> = {
options: PermissionOptionWithLabel[]
onChange: (option: PermissionOption, input: T, feedback?: string) => void
acceptFeedback: string
rejectFeedback: string
focusedOption: string
setFocusedOption: (option: string) => void
handleInputModeToggle: (value: string) => void
yesInputMode: boolean
noInputMode: boolean
}
/**
* Hook for handling file permission dialogs with common logic
*/
export function useFilePermissionDialog<T extends ToolInput>({
filePath,
completionType,
languageName,
toolUseConfirm,
onDone,
onReject,
parseInput,
operationType = 'write',
}: UseFilePermissionDialogProps<T>): UseFilePermissionDialogResult<T> {
const toolPermissionContext = useAppState(s => s.toolPermissionContext)
const [acceptFeedback, setAcceptFeedback] = useState('')
const [rejectFeedback, setRejectFeedback] = useState('')
const [focusedOption, setFocusedOption] = useState('yes')
const [yesInputMode, setYesInputMode] = useState(false)
const [noInputMode, setNoInputMode] = useState(false)
// Track whether user ever entered feedback mode (persists after collapse)
const [yesFeedbackModeEntered, setYesFeedbackModeEntered] = useState(false)
const [noFeedbackModeEntered, setNoFeedbackModeEntered] = useState(false)
// Generate options based on context
const options = useMemo(
() =>
getFilePermissionOptions({
filePath,
toolPermissionContext,
operationType,
onRejectFeedbackChange: setRejectFeedback,
onAcceptFeedbackChange: setAcceptFeedback,
yesInputMode,
noInputMode,
}),
[filePath, toolPermissionContext, operationType, yesInputMode, noInputMode],
)
// Handle option selection using shared handlers
const onChange = useCallback(
(option: PermissionOption, input: T, feedback?: string) => {
const params: PermissionHandlerParams = {
messageId: toolUseConfirm.assistantMessage.message.id,
path: filePath,
toolUseConfirm,
toolPermissionContext,
onDone,
onReject,
completionType,
languageName,
operationType,
}
// Override the input in toolUseConfirm to pass the parsed input
const originalOnAllow = toolUseConfirm.onAllow
toolUseConfirm.onAllow = (
_input: unknown,
permissionUpdates: PermissionUpdate[],
feedback?: string,
) => {
originalOnAllow(input, permissionUpdates, feedback)
}
const handler = PERMISSION_HANDLERS[option.type]
handler(params, {
feedback,
hasFeedback: !!feedback,
enteredFeedbackMode:
option.type === 'accept-once'
? yesFeedbackModeEntered
: noFeedbackModeEntered,
scope: option.type === 'accept-session' ? option.scope : undefined,
})
},
[
filePath,
completionType,
languageName,
toolUseConfirm,
toolPermissionContext,
onDone,
onReject,
operationType,
yesFeedbackModeEntered,
noFeedbackModeEntered,
],
)
// Handler for confirm:cycleMode - select accept-session option
const handleCycleMode = useCallback(() => {
const sessionOption = options.find(o => o.option.type === 'accept-session')
if (sessionOption) {
const parsedInput = parseInput(toolUseConfirm.input)
onChange(sessionOption.option, parsedInput)
}
}, [options, parseInput, toolUseConfirm.input, onChange])
// Register keyboard shortcut handler via keybindings system
useKeybindings(
{ 'confirm:cycleMode': handleCycleMode },
{ context: 'Confirmation' },
)
// Wrap setFocusedOption and reset input mode when navigating away
const handleFocusedOptionChange = useCallback(
(value: string) => {
// Reset input mode when navigating away, but only if no text typed
if (value !== 'yes' && yesInputMode && !acceptFeedback.trim()) {
setYesInputMode(false)
}
if (value !== 'no' && noInputMode && !rejectFeedback.trim()) {
setNoInputMode(false)
}
setFocusedOption(value)
},
[yesInputMode, noInputMode, acceptFeedback, rejectFeedback],
)
// Handle Tab key toggling input mode for Yes/No options
const handleInputModeToggle = useCallback(
(value: string) => {
const analyticsProps = {
toolName: sanitizeToolNameForAnalytics(
toolUseConfirm.tool.name,
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
isMcp: toolUseConfirm.tool.isMcp ?? false,
}
if (value === 'yes') {
if (yesInputMode) {
setYesInputMode(false)
logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps)
} else {
setYesInputMode(true)
setYesFeedbackModeEntered(true)
logEvent('tengu_accept_feedback_mode_entered', analyticsProps)
}
} else if (value === 'no') {
if (noInputMode) {
setNoInputMode(false)
logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps)
} else {
setNoInputMode(true)
setNoFeedbackModeEntered(true)
logEvent('tengu_reject_feedback_mode_entered', analyticsProps)
}
}
},
[yesInputMode, noInputMode, toolUseConfirm],
)
return {
options,
onChange,
acceptFeedback,
rejectFeedback,
focusedOption,
setFocusedOption: handleFocusedOptionChange,
handleInputModeToggle,
yesInputMode,
noInputMode,
}
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
export type ScheduledTaskWizardData = {
name?: string
description?: string
prompt?: string
model?: string
permissionMode?: string
folder?: string
worktree?: boolean
frequency?: string
scheduledTime?: string
cron?: string
}
@@ -0,0 +1,36 @@
import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useContext } from 'react';
/**
* Context to indicate that shell output should be shown in full (not truncated).
* Used to auto-expand the most recent user `!` command output.
*
* This follows the same pattern as MessageResponseContext and SubAgentContext -
* a boolean context that child components can check to modify their behavior.
*/
const ExpandShellOutputContext = React.createContext(false);
export function ExpandShellOutputProvider(t0) {
const $ = _c(2);
const {
children
} = t0;
let t1;
if ($[0] !== children) {
t1 = <ExpandShellOutputContext.Provider value={true}>{children}</ExpandShellOutputContext.Provider>;
$[0] = children;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
/**
* Returns true if this component is rendered inside an ExpandShellOutputProvider,
* indicating the shell output should be shown in full rather than truncated.
*/
export function useExpandShellOutput() {
return useContext(ExpandShellOutputContext);
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUNvbnRleHQiLCJFeHBhbmRTaGVsbE91dHB1dENvbnRleHQiLCJjcmVhdGVDb250ZXh0IiwiRXhwYW5kU2hlbGxPdXRwdXRQcm92aWRlciIsInQwIiwiJCIsIl9jIiwiY2hpbGRyZW4iLCJ0MSIsInVzZUV4cGFuZFNoZWxsT3V0cHV0Il0sInNvdXJjZXMiOlsiRXhwYW5kU2hlbGxPdXRwdXRDb250ZXh0LnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUNvbnRleHQgfSBmcm9tICdyZWFjdCdcblxuLyoqXG4gKiBDb250ZXh0IHRvIGluZGljYXRlIHRoYXQgc2hlbGwgb3V0cHV0IHNob3VsZCBiZSBzaG93biBpbiBmdWxsIChub3QgdHJ1bmNhdGVkKS5cbiAqIFVzZWQgdG8gYXV0by1leHBhbmQgdGhlIG1vc3QgcmVjZW50IHVzZXIgYCFgIGNvbW1hbmQgb3V0cHV0LlxuICpcbiAqIFRoaXMgZm9sbG93cyB0aGUgc2FtZSBwYXR0ZXJuIGFzIE1lc3NhZ2VSZXNwb25zZUNvbnRleHQgYW5kIFN1YkFnZW50Q29udGV4dCAtXG4gKiBhIGJvb2xlYW4gY29udGV4dCB0aGF0IGNoaWxkIGNvbXBvbmVudHMgY2FuIGNoZWNrIHRvIG1vZGlmeSB0aGVpciBiZWhhdmlvci5cbiAqL1xuY29uc3QgRXhwYW5kU2hlbGxPdXRwdXRDb250ZXh0ID0gUmVhY3QuY3JlYXRlQ29udGV4dChmYWxzZSlcblxuZXhwb3J0IGZ1bmN0aW9uIEV4cGFuZFNoZWxsT3V0cHV0UHJvdmlkZXIoe1xuICBjaGlsZHJlbixcbn06IHtcbiAgY2hpbGRyZW46IFJlYWN0LlJlYWN0Tm9kZVxufSk6IFJlYWN0LlJlYWN0Tm9kZSB7XG4gIHJldHVybiAoXG4gICAgPEV4cGFuZFNoZWxsT3V0cHV0Q29udGV4dC5Qcm92aWRlciB2YWx1ZT17dHJ1ZX0+XG4gICAgICB7Y2hpbGRyZW59XG4gICAgPC9FeHBhbmRTaGVsbE91dHB1dENvbnRleHQuUHJvdmlkZXI+XG4gIClcbn1cblxuLyoqXG4gKiBSZXR1cm5zIHRydWUgaWYgdGhpcyBjb21wb25lbnQgaXMgcmVuZGVyZWQgaW5zaWRlIGFuIEV4cGFuZFNoZWxsT3V0cHV0UHJvdmlkZXIsXG4gKiBpbmRpY2F0aW5nIHRoZSBzaGVsbCBvdXRwdXQgc2hvdWxkIGJlIHNob3duIGluIGZ1bGwgcmF0aGVyIHRoYW4gdHJ1bmNhdGVkLlxuICovXG5leHBvcnQgZnVuY3Rpb24gdXNlRXhwYW5kU2hlbGxPdXRwdXQoKTogYm9vbGVhbiB7XG4gIHJldHVybiB1c2VDb250ZXh0KEV4cGFuZFNoZWxsT3V0cHV0Q29udGV4dClcbn1cbiJdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU8sS0FBS0EsS0FBSyxNQUFNLE9BQU87QUFDOUIsU0FBU0MsVUFBVSxRQUFRLE9BQU87O0FBRWxDO0FBQ0E7QUFDQTtBQUNBO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsTUFBTUMsd0JBQXdCLEdBQUdGLEtBQUssQ0FBQ0csYUFBYSxDQUFDLEtBQUssQ0FBQztBQUUzRCxPQUFPLFNBQUFDLDBCQUFBQyxFQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQW1DO0lBQUFDO0VBQUEsSUFBQUgsRUFJekM7RUFBQSxJQUFBSSxFQUFBO0VBQUEsSUFBQUgsQ0FBQSxRQUFBRSxRQUFBO0lBRUdDLEVBQUEsc0NBQTBDLEtBQUksQ0FBSixLQUFHLENBQUMsQ0FDM0NELFNBQU8sQ0FDVixvQ0FBb0M7SUFBQUYsQ0FBQSxNQUFBRSxRQUFBO0lBQUFGLENBQUEsTUFBQUcsRUFBQTtFQUFBO0lBQUFBLEVBQUEsR0FBQUgsQ0FBQTtFQUFBO0VBQUEsT0FGcENHLEVBRW9DO0FBQUE7O0FBSXhDO0FBQ0E7QUFDQTtBQUNBO0FBQ0EsT0FBTyxTQUFBQyxxQkFBQTtFQUFBLE9BQ0VULFVBQVUsQ0FBQ0Msd0JBQXdCLENBQUM7QUFBQSIsImlnbm9yZUxpc3QiOltdfQ==
@@ -0,0 +1,9 @@
export type {
WizardContextValue,
WizardProviderProps,
WizardStepComponent,
} from './types.js'
export { useWizard } from './useWizard.js'
export { WizardDialogLayout } from './WizardDialogLayout.js'
export { WizardNavigationFooter } from './WizardNavigationFooter.js'
export { WizardProvider } from './WizardProvider.js'
@@ -0,0 +1,144 @@
export const PR_TITLE = 'Add Claude Code GitHub Workflow'
export const GITHUB_ACTION_SETUP_DOCS_URL =
'https://github.com/anthropics/claude-code-action/blob/main/docs/setup.md'
export const WORKFLOW_CONTENT = `name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'
`
export const PR_BODY = `## 🤖 Installing Claude Code GitHub App
This PR adds a GitHub Actions workflow that enables Claude Code integration in our repository.
### What is Claude Code?
[Claude Code](https://claude.com/claude-code) is an AI coding agent that can help with:
- Bug fixes and improvements
- Documentation updates
- Implementing new features
- Code reviews and suggestions
- Writing tests
- And more!
### How it works
Once this PR is merged, we'll be able to interact with Claude by mentioning @claude in a pull request or issue comment.
Once the workflow is triggered, Claude will analyze the comment and surrounding context, and execute on the request in a GitHub action.
### Important Notes
- **This workflow won't take effect until this PR is merged**
- **@claude mentions won't work until after the merge is complete**
- The workflow runs automatically whenever Claude is mentioned in PR or issue comments
- Claude gets access to the entire PR or issue context including files, diffs, and previous comments
### Security
- Our Anthropic API key is securely stored as a GitHub Actions secret
- Only users with write access to the repository can trigger the workflow
- All Claude runs are stored in the GitHub Actions run history
- Claude's default tools are limited to reading/writing files and interacting with our repo by creating comments, branches, and commits.
- We can add more allowed tools by adding them to the workflow file like:
\`\`\`
allowed_tools: Bash(npm install),Bash(npm run build),Bash(npm run lint),Bash(npm run test)
\`\`\`
There's more information in the [Claude Code action repo](https://github.com/anthropics/claude-code-action).
After merging this PR, let's try mentioning @claude in a comment on any PR to get started!`
export const CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT = `name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: \${{ secrets.ANTHROPIC_API_KEY }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review \${{ github.repository }}/pull/\${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
`
File diff suppressed because one or more lines are too long
@@ -0,0 +1,3 @@
// Local recovery stub for missing generated SDK types.
// The leaked source tree does not include this codegen artifact.
export {}
@@ -0,0 +1,69 @@
import { c as _c } from "react/compiler-runtime";
import { useCallback, useEffect, useState } from 'react';
import { useNotifications } from 'src/context/notifications.js';
import { getIsRemoteMode } from '../../bootstrap/state.js';
import { getSettingsWithAllErrors } from '../../utils/settings/allErrors.js';
import type { ValidationError } from '../../utils/settings/validation.js';
import { useSettingsChange } from '../useSettingsChange.js';
const SETTINGS_ERRORS_NOTIFICATION_KEY = 'settings-errors';
export function useSettingsErrors() {
const $ = _c(6);
const {
addNotification,
removeNotification
} = useNotifications();
const [errors_0, setErrors] = useState(_temp);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
const {
errors: errors_1
} = getSettingsWithAllErrors();
setErrors(errors_1);
};
$[0] = t0;
} else {
t0 = $[0];
}
const handleSettingsChange = t0;
useSettingsChange(handleSettingsChange);
let t1;
let t2;
if ($[1] !== addNotification || $[2] !== errors_0 || $[3] !== removeNotification) {
t1 = () => {
if (getIsRemoteMode()) {
return;
}
if (errors_0.length > 0) {
const message = `Found ${errors_0.length} settings ${errors_0.length === 1 ? "issue" : "issues"} · /doctor for details`;
addNotification({
key: SETTINGS_ERRORS_NOTIFICATION_KEY,
text: message,
color: "warning",
priority: "high",
timeoutMs: 60000
});
} else {
removeNotification(SETTINGS_ERRORS_NOTIFICATION_KEY);
}
};
t2 = [errors_0, addNotification, removeNotification];
$[1] = addNotification;
$[2] = errors_0;
$[3] = removeNotification;
$[4] = t1;
$[5] = t2;
} else {
t1 = $[4];
t2 = $[5];
}
useEffect(t1, t2);
return errors_0;
}
function _temp() {
const {
errors
} = getSettingsWithAllErrors();
return errors;
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJ1c2VDYWxsYmFjayIsInVzZUVmZmVjdCIsInVzZVN0YXRlIiwidXNlTm90aWZpY2F0aW9ucyIsImdldElzUmVtb3RlTW9kZSIsImdldFNldHRpbmdzV2l0aEFsbEVycm9ycyIsIlZhbGlkYXRpb25FcnJvciIsInVzZVNldHRpbmdzQ2hhbmdlIiwiU0VUVElOR1NfRVJST1JTX05PVElGSUNBVElPTl9LRVkiLCJ1c2VTZXR0aW5nc0Vycm9ycyIsIiQiLCJfYyIsImFkZE5vdGlmaWNhdGlvbiIsInJlbW92ZU5vdGlmaWNhdGlvbiIsImVycm9yc18wIiwic2V0RXJyb3JzIiwiX3RlbXAiLCJ0MCIsIlN5bWJvbCIsImZvciIsImVycm9ycyIsImVycm9yc18xIiwiaGFuZGxlU2V0dGluZ3NDaGFuZ2UiLCJ0MSIsInQyIiwibGVuZ3RoIiwibWVzc2FnZSIsImtleSIsInRleHQiLCJjb2xvciIsInByaW9yaXR5IiwidGltZW91dE1zIl0sInNvdXJjZXMiOlsidXNlU2V0dGluZ3NFcnJvcnMudHN4Il0sInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IHVzZUNhbGxiYWNrLCB1c2VFZmZlY3QsIHVzZVN0YXRlIH0gZnJvbSAncmVhY3QnXG5pbXBvcnQgeyB1c2VOb3RpZmljYXRpb25zIH0gZnJvbSAnc3JjL2NvbnRleHQvbm90aWZpY2F0aW9ucy5qcydcbmltcG9ydCB7IGdldElzUmVtb3RlTW9kZSB9IGZyb20gJy4uLy4uL2Jvb3RzdHJhcC9zdGF0ZS5qcydcbmltcG9ydCB7IGdldFNldHRpbmdzV2l0aEFsbEVycm9ycyB9IGZyb20gJy4uLy4uL3V0aWxzL3NldHRpbmdzL2FsbEVycm9ycy5qcydcbmltcG9ydCB0eXBlIHsgVmFsaWRhdGlvbkVycm9yIH0gZnJvbSAnLi4vLi4vdXRpbHMvc2V0dGluZ3MvdmFsaWRhdGlvbi5qcydcbmltcG9ydCB7IHVzZVNldHRpbmdzQ2hhbmdlIH0gZnJvbSAnLi4vdXNlU2V0dGluZ3NDaGFuZ2UuanMnXG5cbmNvbnN0IFNFVFRJTkdTX0VSUk9SU19OT1RJRklDQVRJT05fS0VZID0gJ3NldHRpbmdzLWVycm9ycydcblxuZXhwb3J0IGZ1bmN0aW9uIHVzZVNldHRpbmdzRXJyb3JzKCk6IFZhbGlkYXRpb25FcnJvcltdIHtcbiAgY29uc3QgeyBhZGROb3RpZmljYXRpb24sIHJlbW92ZU5vdGlmaWNhdGlvbiB9ID0gdXNlTm90aWZpY2F0aW9ucygpXG4gIGNvbnN0IFtlcnJvcnMsIHNldEVycm9yc10gPSB1c2VTdGF0ZTxWYWxpZGF0aW9uRXJyb3JbXT4oKCkgPT4ge1xuICAgIGNvbnN0IHsgZXJyb3JzIH0gPSBnZXRTZXR0aW5nc1dpdGhBbGxFcnJvcnMoKVxuICAgIHJldHVybiBlcnJvcnNcbiAgfSlcblxuICBjb25zdCBoYW5kbGVTZXR0aW5nc0NoYW5nZSA9IHVzZUNhbGxiYWNrKCgpID0+IHtcbiAgICBjb25zdCB7IGVycm9ycyB9ID0gZ2V0U2V0dGluZ3NXaXRoQWxsRXJyb3JzKClcbiAgICBzZXRFcnJvcnMoZXJyb3JzKVxuICB9LCBbXSlcblxuICB1c2VTZXR0aW5nc0NoYW5nZShoYW5kbGVTZXR0aW5nc0NoYW5nZSlcblxuICB1c2VFZmZlY3QoKCkgPT4ge1xuICAgIGlmIChnZXRJc1JlbW90ZU1vZGUoKSkgcmV0dXJuXG4gICAgaWYgKGVycm9ycy5sZW5ndGggPiAwKSB7XG4gICAgICBjb25zdCBtZXNzYWdlID0gYEZvdW5kICR7ZXJyb3JzLmxlbmd0aH0gc2V0dGluZ3MgJHtlcnJvcnMubGVuZ3RoID09PSAxID8gJ2lzc3VlJyA6ICdpc3N1ZXMnfSDCtyAvZG9jdG9yIGZvciBkZXRhaWxzYFxuICAgICAgYWRkTm90aWZpY2F0aW9uKHtcbiAgICAgICAga2V5OiBTRVRUSU5HU19FUlJPUlNfTk9USUZJQ0FUSU9OX0tFWSxcbiAgICAgICAgdGV4dDogbWVzc2FnZSxcbiAgICAgICAgY29sb3I6ICd3YXJuaW5nJyxcbiAgICAgICAgcHJpb3JpdHk6ICdoaWdoJyxcbiAgICAgICAgdGltZW91dE1zOiA2MDAwMCxcbiAgICAgIH0pXG4gICAgfSBlbHNlIHtcbiAgICAgIHJlbW92ZU5vdGlmaWNhdGlvbihTRVRUSU5HU19FUlJPUlNfTk9USUZJQ0FUSU9OX0tFWSlcbiAgICB9XG4gIH0sIFtlcnJvcnMsIGFkZE5vdGlmaWNhdGlvbiwgcmVtb3ZlTm90aWZpY2F0aW9uXSlcblxuICByZXR1cm4gZXJyb3JzXG59XG4iXSwibWFwcGluZ3MiOiI7QUFBQSxTQUFTQSxXQUFXLEVBQUVDLFNBQVMsRUFBRUMsUUFBUSxRQUFRLE9BQU87QUFDeEQsU0FBU0MsZ0JBQWdCLFFBQVEsOEJBQThCO0FBQy9ELFNBQVNDLGVBQWUsUUFBUSwwQkFBMEI7QUFDMUQsU0FBU0Msd0JBQXdCLFFBQVEsbUNBQW1DO0FBQzVFLGNBQWNDLGVBQWUsUUFBUSxvQ0FBb0M7QUFDekUsU0FBU0MsaUJBQWlCLFFBQVEseUJBQXlCO0FBRTNELE1BQU1DLGdDQUFnQyxHQUFHLGlCQUFpQjtBQUUxRCxPQUFPLFNBQUFDLGtCQUFBO0VBQUEsTUFBQUMsQ0FBQSxHQUFBQyxFQUFBO0VBQ0w7SUFBQUMsZUFBQTtJQUFBQztFQUFBLElBQWdEVixnQkFBZ0IsQ0FBQyxDQUFDO0VBQ2xFLE9BQUFXLFFBQUEsRUFBQUMsU0FBQSxJQUE0QmIsUUFBUSxDQUFvQmMsS0FHdkQsQ0FBQztFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBUCxDQUFBLFFBQUFRLE1BQUEsQ0FBQUMsR0FBQTtJQUV1Q0YsRUFBQSxHQUFBQSxDQUFBO01BQ3ZDO1FBQUFHLE1BQUEsRUFBQUM7TUFBQSxJQUFtQmhCLHdCQUF3QixDQUFDLENBQUM7TUFDN0NVLFNBQVMsQ0FBQ0ssUUFBTSxDQUFDO0lBQUEsQ0FDbEI7SUFBQVYsQ0FBQSxNQUFBTyxFQUFBO0VBQUE7SUFBQUEsRUFBQSxHQUFBUCxDQUFBO0VBQUE7RUFIRCxNQUFBWSxvQkFBQSxHQUE2QkwsRUFHdkI7RUFFTlYsaUJBQWlCLENBQUNlLG9CQUFvQixDQUFDO0VBQUEsSUFBQUMsRUFBQTtFQUFBLElBQUFDLEVBQUE7RUFBQSxJQUFBZCxDQUFBLFFBQUFFLGVBQUEsSUFBQUYsQ0FBQSxRQUFBSSxRQUFBLElBQUFKLENBQUEsUUFBQUcsa0JBQUE7SUFFN0JVLEVBQUEsR0FBQUEsQ0FBQTtNQUNSLElBQUluQixlQUFlLENBQUMsQ0FBQztRQUFBO01BQUE7TUFDckIsSUFBSWdCLFFBQU0sQ0FBQUssTUFBTyxHQUFHLENBQUM7UUFDbkIsTUFBQUMsT0FBQSxHQUFnQixTQUFTTixRQUFNLENBQUFLLE1BQU8sYUFBYUwsUUFBTSxDQUFBSyxNQUFPLEtBQUssQ0FBc0IsR0FBeEMsT0FBd0MsR0FBeEMsUUFBd0Msd0JBQXdCO1FBQ25IYixlQUFlLENBQUM7VUFBQWUsR0FBQSxFQUNUbkIsZ0NBQWdDO1VBQUFvQixJQUFBLEVBQy9CRixPQUFPO1VBQUFHLEtBQUEsRUFDTixTQUFTO1VBQUFDLFFBQUEsRUFDTixNQUFNO1VBQUFDLFNBQUEsRUFDTDtRQUNiLENBQUMsQ0FBQztNQUFBO1FBRUZsQixrQkFBa0IsQ0FBQ0wsZ0NBQWdDLENBQUM7TUFBQTtJQUNyRCxDQUNGO0lBQUVnQixFQUFBLElBQUNKLFFBQU0sRUFBRVIsZUFBZSxFQUFFQyxrQkFBa0IsQ0FBQztJQUFBSCxDQUFBLE1BQUFFLGVBQUE7SUFBQUYsQ0FBQSxNQUFBSSxRQUFBO0lBQUFKLENBQUEsTUFBQUcsa0JBQUE7SUFBQUgsQ0FBQSxNQUFBYSxFQUFBO0lBQUFiLENBQUEsTUFBQWMsRUFBQTtFQUFBO0lBQUFELEVBQUEsR0FBQWIsQ0FBQTtJQUFBYyxFQUFBLEdBQUFkLENBQUE7RUFBQTtFQWRoRFQsU0FBUyxDQUFDc0IsRUFjVCxFQUFFQyxFQUE2QyxDQUFDO0VBQUEsT0FFMUNKLFFBQU07QUFBQTtBQTlCUixTQUFBSixNQUFBO0VBR0g7SUFBQUk7RUFBQSxJQUFtQmYsd0JBQXdCLENBQUMsQ0FBQztFQUFBLE9BQ3RDZSxNQUFNO0FBQUEiLCJpZ25vcmVMaXN0IjpbXX0=
@@ -0,0 +1,41 @@
import { useEffect, useRef } from 'react'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import {
type Notification,
useNotifications,
} from '../../context/notifications.js'
import { logError } from '../../utils/log.js'
type Result = Notification | Notification[] | null
/**
* Fires notification(s) once on mount. Encapsulates the remote-mode gate and
* once-per-session ref guard that was hand-rolled across 10+ notifs/ hooks.
*
* The compute fn runs exactly once on first effect. Return null to skip,
* a Notification to fire one, or an array to fire several. Sync or async.
* Rejections are routed to logError.
*/
export function useStartupNotification(
compute: () => Result | Promise<Result>,
): void {
const { addNotification } = useNotifications()
const hasRunRef = useRef(false)
const computeRef = useRef(compute)
computeRef.current = compute
useEffect(() => {
if (getIsRemoteMode() || hasRunRef.current) return
hasRunRef.current = true
void Promise.resolve()
.then(() => computeRef.current())
.then(result => {
if (!result) return
for (const n of Array.isArray(result) ? result : [result]) {
addNotification(n)
}
})
.catch(logError)
}, [addNotification])
}
@@ -0,0 +1,51 @@
import chalk from 'chalk'
type PlaceholderRendererProps = {
placeholder?: string
value: string
showCursor?: boolean
focus?: boolean
terminalFocus: boolean
invert?: (text: string) => string
hidePlaceholderText?: boolean
}
export function renderPlaceholder({
placeholder,
value,
showCursor,
focus,
terminalFocus = true,
invert = chalk.inverse,
hidePlaceholderText = false,
}: PlaceholderRendererProps): {
renderedPlaceholder: string | undefined
showPlaceholder: boolean
} {
let renderedPlaceholder: string | undefined = undefined
if (placeholder) {
if (hidePlaceholderText) {
// Voice recording: show only the cursor, no placeholder text
renderedPlaceholder =
showCursor && focus && terminalFocus ? invert(' ') : ''
} else {
renderedPlaceholder = chalk.dim(placeholder)
// Show inverse cursor only when both input and terminal are focused
if (showCursor && focus && terminalFocus) {
renderedPlaceholder =
placeholder.length > 0
? invert(placeholder[0]!) + chalk.dim(placeholder.slice(1))
: invert(' ')
}
}
}
const showPlaceholder = value.length === 0 && Boolean(placeholder)
return {
renderedPlaceholder,
showPlaceholder,
}
}
@@ -0,0 +1,388 @@
import { feature } from 'bun:bundle'
import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { sanitizeToolNameForAnalytics } from 'src/services/analytics/metadata.js'
import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js'
import type {
ToolPermissionContext,
Tool as ToolType,
ToolUseContext,
} from '../../Tool.js'
import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js'
import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js'
import type { AssistantMessage } from '../../types/message.js'
import type {
PendingClassifierCheck,
PermissionAllowDecision,
PermissionDecisionReason,
PermissionDenyDecision,
} from '../../types/permissions.js'
import { setClassifierApproval } from '../../utils/classifierApprovals.js'
import { logForDebugging } from '../../utils/debug.js'
import { executePermissionRequestHooks } from '../../utils/hooks.js'
import {
REJECT_MESSAGE,
REJECT_MESSAGE_WITH_REASON_PREFIX,
SUBAGENT_REJECT_MESSAGE,
SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX,
withMemoryCorrectionHint,
} from '../../utils/messages.js'
import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'
import {
applyPermissionUpdates,
persistPermissionUpdates,
supportsPersistence,
} from '../../utils/permissions/PermissionUpdate.js'
import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'
import {
logPermissionDecision,
type PermissionDecisionArgs,
} from './permissionLogging.js'
type PermissionApprovalSource =
| { type: 'hook'; permanent?: boolean }
| { type: 'user'; permanent: boolean }
| { type: 'classifier' }
type PermissionRejectionSource =
| { type: 'hook' }
| { type: 'user_abort' }
| { type: 'user_reject'; hasFeedback: boolean }
// Generic interface for permission queue operations, decoupled from React.
// In the REPL, these are backed by React state.
type PermissionQueueOps = {
push(item: ToolUseConfirm): void
remove(toolUseID: string): void
update(toolUseID: string, patch: Partial<ToolUseConfirm>): void
}
type ResolveOnce<T> = {
resolve(value: T): void
isResolved(): boolean
/**
* Atomically check-and-mark as resolved. Returns true if this caller
* won the race (nobody else has resolved yet), false otherwise.
* Use this in async callbacks BEFORE awaiting, to close the window
* between the `isResolved()` check and the actual `resolve()` call.
*/
claim(): boolean
}
function createResolveOnce<T>(resolve: (value: T) => void): ResolveOnce<T> {
let claimed = false
let delivered = false
return {
resolve(value: T) {
if (delivered) return
delivered = true
claimed = true
resolve(value)
},
isResolved() {
return claimed
},
claim() {
if (claimed) return false
claimed = true
return true
},
}
}
function createPermissionContext(
tool: ToolType,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
setToolPermissionContext: (context: ToolPermissionContext) => void,
queueOps?: PermissionQueueOps,
) {
const messageId = assistantMessage.message.id
const ctx = {
tool,
input,
toolUseContext,
assistantMessage,
messageId,
toolUseID,
logDecision(
args: PermissionDecisionArgs,
opts?: {
input?: Record<string, unknown>
permissionPromptStartTimeMs?: number
},
) {
logPermissionDecision(
{
tool,
input: opts?.input ?? input,
toolUseContext,
messageId,
toolUseID,
},
args,
opts?.permissionPromptStartTimeMs,
)
},
logCancelled() {
logEvent('tengu_tool_use_cancelled', {
messageID:
messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
toolName: sanitizeToolNameForAnalytics(tool.name),
})
},
async persistPermissions(updates: PermissionUpdate[]) {
if (updates.length === 0) return false
persistPermissionUpdates(updates)
const appState = toolUseContext.getAppState()
setToolPermissionContext(
applyPermissionUpdates(appState.toolPermissionContext, updates),
)
return updates.some(update => supportsPersistence(update.destination))
},
resolveIfAborted(resolve: (decision: PermissionDecision) => void) {
if (!toolUseContext.abortController.signal.aborted) return false
this.logCancelled()
resolve(this.cancelAndAbort(undefined, true))
return true
},
cancelAndAbort(
feedback?: string,
isAbort?: boolean,
contentBlocks?: ContentBlockParam[],
): PermissionDecision {
const sub = !!toolUseContext.agentId
const baseMessage = feedback
? `${sub ? SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX : REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}`
: sub
? SUBAGENT_REJECT_MESSAGE
: REJECT_MESSAGE
const message = sub ? baseMessage : withMemoryCorrectionHint(baseMessage)
if (isAbort || (!feedback && !contentBlocks?.length && !sub)) {
logForDebugging(
`Aborting: tool=${tool.name} isAbort=${isAbort} hasFeedback=${!!feedback} isSubagent=${sub}`,
)
toolUseContext.abortController.abort()
}
return { behavior: 'ask', message, contentBlocks }
},
...(feature('BASH_CLASSIFIER')
? {
async tryClassifier(
pendingClassifierCheck: PendingClassifierCheck | undefined,
updatedInput: Record<string, unknown> | undefined,
): Promise<PermissionDecision | null> {
if (tool.name !== BASH_TOOL_NAME || !pendingClassifierCheck) {
return null
}
const classifierDecision = await awaitClassifierAutoApproval(
pendingClassifierCheck,
toolUseContext.abortController.signal,
toolUseContext.options.isNonInteractiveSession,
)
if (!classifierDecision) {
return null
}
if (
feature('TRANSCRIPT_CLASSIFIER') &&
classifierDecision.type === 'classifier'
) {
const matchedRule = classifierDecision.reason.match(
/^Allowed by prompt rule: "(.+)"$/,
)?.[1]
if (matchedRule) {
setClassifierApproval(toolUseID, matchedRule)
}
}
logPermissionDecision(
{ tool, input, toolUseContext, messageId, toolUseID },
{ decision: 'accept', source: { type: 'classifier' } },
undefined,
)
return {
behavior: 'allow' as const,
updatedInput: updatedInput ?? input,
userModified: false,
decisionReason: classifierDecision,
}
},
}
: {}),
async runHooks(
permissionMode: string | undefined,
suggestions: PermissionUpdate[] | undefined,
updatedInput?: Record<string, unknown>,
permissionPromptStartTimeMs?: number,
): Promise<PermissionDecision | null> {
for await (const hookResult of executePermissionRequestHooks(
tool.name,
toolUseID,
input,
toolUseContext,
permissionMode,
suggestions,
toolUseContext.abortController.signal,
)) {
if (hookResult.permissionRequestResult) {
const decision = hookResult.permissionRequestResult
if (decision.behavior === 'allow') {
const finalInput = decision.updatedInput ?? updatedInput ?? input
return await this.handleHookAllow(
finalInput,
decision.updatedPermissions ?? [],
permissionPromptStartTimeMs,
)
} else if (decision.behavior === 'deny') {
this.logDecision(
{ decision: 'reject', source: { type: 'hook' } },
{ permissionPromptStartTimeMs },
)
if (decision.interrupt) {
logForDebugging(
`Hook interrupt: tool=${tool.name} hookMessage=${decision.message}`,
)
toolUseContext.abortController.abort()
}
return this.buildDeny(
decision.message || 'Permission denied by hook',
{
type: 'hook',
hookName: 'PermissionRequest',
reason: decision.message,
},
)
}
}
}
return null
},
buildAllow(
updatedInput: Record<string, unknown>,
opts?: {
userModified?: boolean
decisionReason?: PermissionDecisionReason
acceptFeedback?: string
contentBlocks?: ContentBlockParam[]
},
): PermissionAllowDecision {
return {
behavior: 'allow' as const,
updatedInput,
userModified: opts?.userModified ?? false,
...(opts?.decisionReason && { decisionReason: opts.decisionReason }),
...(opts?.acceptFeedback && { acceptFeedback: opts.acceptFeedback }),
...(opts?.contentBlocks &&
opts.contentBlocks.length > 0 && {
contentBlocks: opts.contentBlocks,
}),
}
},
buildDeny(
message: string,
decisionReason: PermissionDecisionReason,
): PermissionDenyDecision {
return { behavior: 'deny' as const, message, decisionReason }
},
async handleUserAllow(
updatedInput: Record<string, unknown>,
permissionUpdates: PermissionUpdate[],
feedback?: string,
permissionPromptStartTimeMs?: number,
contentBlocks?: ContentBlockParam[],
decisionReason?: PermissionDecisionReason,
): Promise<PermissionAllowDecision> {
const acceptedPermanentUpdates =
await this.persistPermissions(permissionUpdates)
this.logDecision(
{
decision: 'accept',
source: { type: 'user', permanent: acceptedPermanentUpdates },
},
{ input: updatedInput, permissionPromptStartTimeMs },
)
const userModified = tool.inputsEquivalent
? !tool.inputsEquivalent(input, updatedInput)
: false
const trimmedFeedback = feedback?.trim()
return this.buildAllow(updatedInput, {
userModified,
decisionReason,
acceptFeedback: trimmedFeedback || undefined,
contentBlocks,
})
},
async handleHookAllow(
finalInput: Record<string, unknown>,
permissionUpdates: PermissionUpdate[],
permissionPromptStartTimeMs?: number,
): Promise<PermissionAllowDecision> {
const acceptedPermanentUpdates =
await this.persistPermissions(permissionUpdates)
this.logDecision(
{
decision: 'accept',
source: { type: 'hook', permanent: acceptedPermanentUpdates },
},
{ input: finalInput, permissionPromptStartTimeMs },
)
return this.buildAllow(finalInput, {
decisionReason: { type: 'hook', hookName: 'PermissionRequest' },
})
},
pushToQueue(item: ToolUseConfirm) {
queueOps?.push(item)
},
removeFromQueue() {
queueOps?.remove(toolUseID)
},
updateQueueItem(patch: Partial<ToolUseConfirm>) {
queueOps?.update(toolUseID, patch)
},
}
return Object.freeze(ctx)
}
type PermissionContext = ReturnType<typeof createPermissionContext>
/**
* Create a PermissionQueueOps backed by a React state setter.
* This is the bridge between React's `setToolUseConfirmQueue` and the
* generic queue interface used by PermissionContext.
*/
function createPermissionQueueOps(
setToolUseConfirmQueue: React.Dispatch<
React.SetStateAction<ToolUseConfirm[]>
>,
): PermissionQueueOps {
return {
push(item: ToolUseConfirm) {
setToolUseConfirmQueue(queue => [...queue, item])
},
remove(toolUseID: string) {
setToolUseConfirmQueue(queue =>
queue.filter(item => item.toolUseID !== toolUseID),
)
},
update(toolUseID: string, patch: Partial<ToolUseConfirm>) {
setToolUseConfirmQueue(queue =>
queue.map(item =>
item.toolUseID === toolUseID ? { ...item, ...patch } : item,
),
)
},
}
}
export { createPermissionContext, createPermissionQueueOps, createResolveOnce }
export type {
PermissionContext,
PermissionApprovalSource,
PermissionQueueOps,
PermissionRejectionSource,
ResolveOnce,
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,77 @@
import { useEffect, useRef } from 'react'
import { useNotifications } from '../context/notifications.js'
import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'
import { hasImageInClipboard } from '../utils/imagePaste.js'
const NOTIFICATION_KEY = 'clipboard-image-hint'
// Small debounce to batch rapid focus changes
const FOCUS_CHECK_DEBOUNCE_MS = 1000
// Don't show the hint more than once per this interval
const HINT_COOLDOWN_MS = 30000
/**
* Hook that shows a notification when the terminal regains focus
* and the clipboard contains an image.
*
* @param isFocused - Whether the terminal is currently focused
* @param enabled - Whether image paste is enabled (onImagePaste is defined)
*/
export function useClipboardImageHint(
isFocused: boolean,
enabled: boolean,
): void {
const { addNotification } = useNotifications()
const lastFocusedRef = useRef(isFocused)
const lastHintTimeRef = useRef(0)
const checkTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
// Only trigger on focus regain (was unfocused, now focused)
const wasFocused = lastFocusedRef.current
lastFocusedRef.current = isFocused
if (!enabled || !isFocused || wasFocused) {
return
}
// Clear any pending check
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current)
}
// Small debounce to batch rapid focus changes
checkTimeoutRef.current = setTimeout(
async (checkTimeoutRef, lastHintTimeRef, addNotification) => {
checkTimeoutRef.current = null
// Check cooldown to avoid spamming the user
const now = Date.now()
if (now - lastHintTimeRef.current < HINT_COOLDOWN_MS) {
return
}
// Check if clipboard has an image (async osascript call)
if (await hasImageInClipboard()) {
lastHintTimeRef.current = now
addNotification({
key: NOTIFICATION_KEY,
text: `Image in clipboard · ${getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')} to paste`,
priority: 'immediate',
timeoutMs: 8000,
})
}
},
FOCUS_CHECK_DEBOUNCE_MS,
checkTimeoutRef,
lastHintTimeRef,
addNotification,
)
return () => {
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current)
checkTimeoutRef.current = null
}
}
}, [isFocused, enabled, addNotification])
}
@@ -0,0 +1,110 @@
import type { StructuredPatchHunk } from 'diff'
import { useEffect, useMemo, useState } from 'react'
import {
fetchGitDiff,
fetchGitDiffHunks,
type GitDiffResult,
type GitDiffStats,
} from '../utils/gitDiff.js'
const MAX_LINES_PER_FILE = 400
export type DiffFile = {
path: string
linesAdded: number
linesRemoved: number
isBinary: boolean
isLargeFile: boolean
isTruncated: boolean
isNewFile?: boolean
isUntracked?: boolean
}
export type DiffData = {
stats: GitDiffStats | null
files: DiffFile[]
hunks: Map<string, StructuredPatchHunk[]>
loading: boolean
}
/**
* Hook to fetch current git diff data on demand.
* Fetches both stats and hunks when component mounts.
*/
export function useDiffData(): DiffData {
const [diffResult, setDiffResult] = useState<GitDiffResult | null>(null)
const [hunks, setHunks] = useState<Map<string, StructuredPatchHunk[]>>(
new Map(),
)
const [loading, setLoading] = useState(true)
// Fetch diff data on mount
useEffect(() => {
let cancelled = false
async function loadDiffData() {
try {
// Fetch both stats and hunks
const [statsResult, hunksResult] = await Promise.all([
fetchGitDiff(),
fetchGitDiffHunks(),
])
if (!cancelled) {
setDiffResult(statsResult)
setHunks(hunksResult)
setLoading(false)
}
} catch (_error) {
if (!cancelled) {
setDiffResult(null)
setHunks(new Map())
setLoading(false)
}
}
}
void loadDiffData()
return () => {
cancelled = true
}
}, [])
return useMemo(() => {
if (!diffResult) {
return { stats: null, files: [], hunks: new Map(), loading }
}
const { stats, perFileStats } = diffResult
const files: DiffFile[] = []
// Iterate over perFileStats to get all files including large/skipped ones
for (const [path, fileStats] of perFileStats) {
const fileHunks = hunks.get(path)
const isUntracked = fileStats.isUntracked ?? false
// Detect large file (in perFileStats but not in hunks, and not binary/untracked)
const isLargeFile = !fileStats.isBinary && !isUntracked && !fileHunks
// Detect truncated file (total > limit means we truncated)
const totalLines = fileStats.added + fileStats.removed
const isTruncated =
!isLargeFile && !fileStats.isBinary && totalLines > MAX_LINES_PER_FILE
files.push({
path,
linesAdded: fileStats.added,
linesRemoved: fileStats.removed,
isBinary: fileStats.isBinary,
isLargeFile,
isTruncated,
isUntracked,
})
}
files.sort((a, b) => a.path.localeCompare(b.path))
return { stats, files, hunks, loading: false }
}, [diffResult, hunks, loading])
}
@@ -0,0 +1,24 @@
import { useKeybindings } from '../keybindings/useKeybinding.js'
import { type ExitState, useExitOnCtrlCD } from './useExitOnCtrlCD.js'
export type { ExitState }
/**
* Convenience hook that wires up useExitOnCtrlCD with useKeybindings.
*
* This is the standard way to use useExitOnCtrlCD in components.
* The separation exists to avoid import cycles - useExitOnCtrlCD.ts
* doesn't import from the keybindings module directly.
*
* @param onExit - Optional custom exit handler
* @param onInterrupt - Optional callback for features to handle interrupt (ctrl+c).
* Return true if handled, false to fall through to double-press exit.
* @param isActive - Whether the keybinding is active (default true).
*/
export function useExitOnCtrlCDWithKeybindings(
onExit?: () => void,
onInterrupt?: () => boolean,
isActive?: boolean,
): ExitState {
return useExitOnCtrlCD(useKeybindings, onInterrupt, onExit, isActive)
}
@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import type { MCPServerConnection } from '../services/mcp/types.js'
export type IdeStatus = 'connected' | 'disconnected' | 'pending' | null
type IdeConnectionResult = {
status: IdeStatus
ideName: string | null
}
export function useIdeConnectionStatus(
mcpClients?: MCPServerConnection[],
): IdeConnectionResult {
return useMemo(() => {
const ideClient = mcpClients?.find(client => client.name === 'ide')
if (!ideClient) {
return { status: null, ideName: null }
}
// Extract IDE name from config if available
const config = ideClient.config
const ideName =
config.type === 'sse-ide' || config.type === 'ws-ide'
? config.ideName
: null
if (ideClient.type === 'connected') {
return { status: 'connected', ideName }
}
if (ideClient.type === 'pending') {
return { status: 'pending', ideName }
}
return { status: 'disconnected', ideName }
}, [mcpClients])
}
@@ -0,0 +1,133 @@
import { useMemo, useRef } from 'react'
import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
import type { Message } from '../types/message.js'
import { getUserMessageText } from '../utils/messages.js'
const EXTERNAL_COMMAND_PATTERNS = [
/\bcurl\b/,
/\bwget\b/,
/\bssh\b/,
/\bkubectl\b/,
/\bsrun\b/,
/\bdocker\b/,
/\bbq\b/,
/\bgsutil\b/,
/\bgcloud\b/,
/\baws\b/,
/\bgit\s+push\b/,
/\bgit\s+pull\b/,
/\bgit\s+fetch\b/,
/\bgh\s+(pr|issue)\b/,
/\bnc\b/,
/\bncat\b/,
/\btelnet\b/,
/\bftp\b/,
]
const FRICTION_PATTERNS = [
// "No," or "No!" at start — comma/exclamation implies correction tone
// (avoids "No problem", "No thanks", "No I think we should...")
/^no[,!]\s/i,
// Direct corrections about Claude's output
/\bthat'?s (wrong|incorrect|not (what|right|correct))\b/i,
/\bnot what I (asked|wanted|meant|said)\b/i,
// Referencing prior instructions Claude missed
/\bI (said|asked|wanted|told you|already said)\b/i,
// Questioning Claude's actions
/\bwhy did you\b/i,
/\byou should(n'?t| not)? have\b/i,
/\byou were supposed to\b/i,
// Explicit retry/revert of Claude's work
/\btry again\b/i,
/\b(undo|revert) (that|this|it|what you)\b/i,
]
export function isSessionContainerCompatible(messages: Message[]): boolean {
for (const msg of messages) {
if (msg.type !== 'assistant') {
continue
}
const content = msg.message.content
if (!Array.isArray(content)) {
continue
}
for (const block of content) {
if (block.type !== 'tool_use' || !('name' in block)) {
continue
}
const toolName = block.name as string
if (toolName.startsWith('mcp__')) {
return false
}
if (toolName === BASH_TOOL_NAME) {
const input = (block as { input?: Record<string, unknown> }).input
const command = (input?.command as string) || ''
if (EXTERNAL_COMMAND_PATTERNS.some(p => p.test(command))) {
return false
}
}
}
}
return true
}
export function hasFrictionSignal(messages: Message[]): boolean {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]!
if (msg.type !== 'user') {
continue
}
const text = getUserMessageText(msg)
if (!text) {
continue
}
return FRICTION_PATTERNS.some(p => p.test(text))
}
return false
}
const MIN_SUBMIT_COUNT = 3
const COOLDOWN_MS = 30 * 60 * 1000
export function useIssueFlagBanner(
messages: Message[],
submitCount: number,
): boolean {
if (process.env.USER_TYPE !== 'ant') {
return false
}
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const lastTriggeredAtRef = useRef(0)
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const activeForSubmitRef = useRef(-1)
// Memoize the O(messages) scans. This hook runs on every REPL render
// (including every keystroke), but messages is stable during typing.
// isSessionContainerCompatible walks all messages + regex-tests each
// bash command — by far the heaviest work here.
// biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
const shouldTrigger = useMemo(
() => isSessionContainerCompatible(messages) && hasFrictionSignal(messages),
[messages],
)
// Keep showing the banner until the user submits another message
if (activeForSubmitRef.current === submitCount) {
return true
}
if (Date.now() - lastTriggeredAtRef.current < COOLDOWN_MS) {
return false
}
if (submitCount < MIN_SUBMIT_COUNT) {
return false
}
if (!shouldTrigger) {
return false
}
lastTriggeredAtRef.current = Date.now()
activeForSubmitRef.current = submitCount
return true
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,15 @@
import { useContext } from 'react'
import {
type TerminalSize,
TerminalSizeContext,
} from 'src/ink/components/TerminalSizeContext.js'
export function useTerminalSize(): TerminalSize {
const size = useContext(TerminalSizeContext)
if (!size) {
throw new Error('useTerminalSize must be used within an Ink App component')
}
return size
}
@@ -0,0 +1,34 @@
import { useState } from 'react'
import { major, minor, patch } from 'semver'
export function getSemverPart(version: string): string {
return `${major(version, { loose: true })}.${minor(version, { loose: true })}.${patch(version, { loose: true })}`
}
export function shouldShowUpdateNotification(
updatedVersion: string,
lastNotifiedSemver: string | null,
): boolean {
const updatedSemver = getSemverPart(updatedVersion)
return updatedSemver !== lastNotifiedSemver
}
export function useUpdateNotification(
updatedVersion: string | null | undefined,
initialVersion: string = MACRO.VERSION,
): string | null {
const [lastNotifiedSemver, setLastNotifiedSemver] = useState<string | null>(
() => getSemverPart(initialVersion),
)
if (!updatedVersion) {
return null
}
const updatedSemver = getSemverPart(updatedVersion)
if (updatedSemver !== lastNotifiedSemver) {
setLastNotifiedSemver(updatedSemver)
return updatedSemver
}
return null
}
@@ -0,0 +1,25 @@
import { useMemo } from 'react'
import { useAppState } from '../state/AppState.js'
import {
hasVoiceAuth,
isVoiceGrowthBookEnabled,
} from '../voice/voiceModeEnabled.js'
/**
* Combines user intent (settings.voiceEnabled) with auth + GB kill-switch.
* Only the auth half is memoized on authVersion — it's the expensive one
* (cold getClaudeAIOAuthTokens memoize → sync `security` spawn, ~60ms/call,
* ~180ms total in profile v5 when token refresh cleared the cache mid-session).
* GB is a cheap cached-map lookup and stays outside the memo so a mid-session
* kill-switch flip still takes effect on the next render.
*
* authVersion bumps on /login only. Background token refresh leaves it alone
* (user is still authed), so the auth memo stays correct without re-eval.
*/
export function useVoiceEnabled(): boolean {
const userIntent = useAppState(s => s.settings.voiceEnabled === true)
const authVersion = useAppState(s => s.authVersion)
// eslint-disable-next-line react-hooks/exhaustive-deps
const authed = useMemo(hasVoiceAuth, [authVersion])
return userIntent && authed && isVoiceGrowthBookEnabled()
}
@@ -0,0 +1,74 @@
/**
* Cross-platform terminal clearing with scrollback support.
* Detects modern terminals that support ESC[3J for clearing scrollback.
*/
import {
CURSOR_HOME,
csi,
ERASE_SCREEN,
ERASE_SCROLLBACK,
} from './termio/csi.js'
// HVP (Horizontal Vertical Position) - legacy Windows cursor home
const CURSOR_HOME_WINDOWS = csi(0, 'f')
function isWindowsTerminal(): boolean {
return process.platform === 'win32' && !!process.env.WT_SESSION
}
function isMintty(): boolean {
// mintty 3.1.5+ sets TERM_PROGRAM to 'mintty'
if (process.env.TERM_PROGRAM === 'mintty') {
return true
}
// GitBash/MSYS2/MINGW use mintty and set MSYSTEM
if (process.platform === 'win32' && process.env.MSYSTEM) {
return true
}
return false
}
function isModernWindowsTerminal(): boolean {
// Windows Terminal sets WT_SESSION environment variable
if (isWindowsTerminal()) {
return true
}
// VS Code integrated terminal on Windows with ConPTY support
if (
process.platform === 'win32' &&
process.env.TERM_PROGRAM === 'vscode' &&
process.env.TERM_PROGRAM_VERSION
) {
return true
}
// mintty (GitBash/MSYS2/Cygwin) supports modern escape sequences
if (isMintty()) {
return true
}
return false
}
/**
* Returns the ANSI escape sequence to clear the terminal including scrollback.
* Automatically detects terminal capabilities.
*/
export function getClearTerminalSequence(): string {
if (process.platform === 'win32') {
if (isModernWindowsTerminal()) {
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
} else {
// Legacy Windows console - can't clear scrollback
return ERASE_SCREEN + CURSOR_HOME_WINDOWS
}
}
return ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME
}
/**
* Clears the terminal screen. On supported terminals, also clears scrollback.
*/
export const clearTerminal = getClearTerminalSequence()

Some files were not shown because too many files have changed in this diff Show More