first commit

This commit is contained in:
H
2026-04-03 13:01:19 +08:00
commit 538eced414
2575 changed files with 645911 additions and 0 deletions

Binary file not shown.

View File

@@ -0,0 +1,87 @@
import axios from 'axios'
import { getOauthConfig } from '../constants/oauth.js'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import { logForDebugging } from '../utils/debug.js'
import { getOAuthHeaders, prepareApiRequest } from '../utils/teleport/api.js'
export const HISTORY_PAGE_SIZE = 100
export type HistoryPage = {
/** 页面内的 chronological 顺序。 */
events: SDKMessage[]
/** 当前页面中最旧的事件 ID → 用于获取下一页的 before_id 游标。 */
firstId: string | null
/** true = 存在更旧的事件。 */
hasMore: boolean
}
type SessionEventsResponse = {
data: SDKMessage[]
has_more: boolean
first_id: string | null
last_id: string | null
}
export type HistoryAuthCtx = {
baseUrl: string
headers: Record<string, string>
}
/** 准备 auth + headers + base URL一次性完成跨页面复用。 */
export async function createHistoryAuthCtx(
sessionId: string,
): Promise<HistoryAuthCtx> {
const { accessToken, orgUUID } = await prepareApiRequest()
return {
baseUrl: `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events`,
headers: {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
},
}
}
async function fetchPage(
ctx: HistoryAuthCtx,
params: Record<string, string | number | boolean>,
label: string,
): Promise<HistoryPage | null> {
const resp = await axios
.get<SessionEventsResponse>(ctx.baseUrl, {
headers: ctx.headers,
params,
timeout: 15000,
validateStatus: () => true,
})
.catch(() => null)
if (!resp || resp.status !== 200) {
logForDebugging(`[${label}] HTTP ${resp?.status ?? 'error'}`)
return null
}
return {
events: Array.isArray(resp.data.data) ? resp.data.data : [],
firstId: resp.data.first_id,
hasMore: resp.data.has_more,
}
}
/**
* 最新页面:最后 `limit` 个事件,按时间顺序排列,通过 anchor_to_latest。
* has_more=true 表示存在更旧的事件。
*/
export async function fetchLatestEvents(
ctx: HistoryAuthCtx,
limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null> {
return fetchPage(ctx, { limit, anchor_to_latest: true }, 'fetchLatestEvents')
}
/** 更旧的页面:紧接在 `beforeId` 游标之前的 events。 */
export async function fetchOlderEvents(
ctx: HistoryAuthCtx,
beforeId: string,
limit = HISTORY_PAGE_SIZE,
): Promise<HistoryPage | null> {
return fetchPage(ctx, { limit, before_id: beforeId }, 'fetchOlderEvents')
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,586 @@
import axios from 'axios'
import { debugBody, extractErrorDetail } from './debugUtils.js'
import {
BRIDGE_LOGIN_INSTRUCTION,
type BridgeApiClient,
type BridgeConfig,
type PermissionResponseEvent,
type WorkResponse,
} from './types.js'
type BridgeApiDeps = {
baseUrl: string
getAccessToken: () => string | undefined
runnerVersion: string
onDebug?: (msg: string) => void
/**
* Called on 401 to attempt OAuth token refresh. Returns true if refreshed,
* in which case the request is retried once. Injected because
* handleOAuth401Error from utils/auth.ts transitively pulls in config.ts →
* file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
* (~1300 modules). Daemon callers using env-var tokens omit this — their
* tokens don't refresh, so 401 goes straight to BridgeFatalError.
* 在 401 时调用以尝试 OAuth token 刷新。如果刷新成功返回 true
* 在这种情况下请求重试一次。注入是因为 utils/auth.ts 中的
* handleOAuth401Error 传递性地引入 config.ts →
* file.ts → permissions/filesystem.ts → sessionStorage.ts → commands.ts
*~1300 个模块)。使用 env-var tokens 的 Daemon 调用者省略此 —
* 它们的 tokens 不会刷新,因此 401 直接进入 BridgeFatalError。
*/
onAuth401?: (staleAccessToken: string) => Promise<boolean>
/**
* Returns the trusted device token to send as X-Trusted-Device-Token on
* bridge API calls. Bridge sessions have SecurityTier=ELEVATED on the
* server (CCR v2); when the server's enforcement flag is on,
* ConnectBridgeWorker requires a trusted device at JWT-issuance.
* Optional — when absent or returning undefined, the header is omitted
* and the server falls through to its flag-off/no-op path. The CLI-side
* gate is tengu_sessions_elevated_auth_enforcement (see trustedDevice.ts).
* 返回作为 X-Trusted-Device-Token 发送的 trusted device token
* 用于 bridge API 调用。Bridge 会话在服务器上有 SecurityTier=ELEVATEDCCR v2
* 当服务器的 enforcement flag 开启时,
* ConnectBridgeWorker 在 JWT 发放时需要 trusted device。
* 可选 — 当不存在或返回 undefined 时,省略 header
* 服务器进入其 flag-off/no-op 路径。CLI 侧的门禁是
* tengu_sessions_elevated_auth_enforcement见 trustedDevice.ts
*/
getTrustedDeviceToken?: () => string | undefined
}
const BETA_HEADER = 'environments-2025-11-01'
/** Allowlist pattern for server-provided IDs used in URL path segments. */
/** 用于 URL 路径段中服务器提供的 ID 的允许列表模式。 */
const SAFE_ID_PATTERN = /^[a-zA-Z0-9_-]+$/
/**
* Validate that a server-provided ID is safe to interpolate into a URL path.
* Prevents path traversal (e.g. `../../admin`) and injection via IDs that
* contain slashes, dots, or other special characters.
* 验证服务器提供的 ID 是否安全地插入到 URL 路径中。
* 防止路径遍历(例如 `../../admin`)和通过包含斜线、
* 点或其他特殊字符的 ID 进行注入。
*/
export function validateBridgeId(id: string, label: string): string {
if (!id || !SAFE_ID_PATTERN.test(id)) {
throw new Error(`Invalid ${label}: contains unsafe characters`)
}
return id
}
/** Fatal bridge errors that should not be retried (e.g. auth failures). */
/** 不应重试的 fatal bridge 错误(例如 auth 失败)。 */
export class BridgeFatalError extends Error {
readonly status: number
/** Server-provided error type, e.g. "environment_expired". */
/** 服务器提供的错误类型,例如 "environment_expired"。 */
readonly errorType: string | undefined
constructor(message: string, status: number, errorType?: string) {
super(message)
this.name = 'BridgeFatalError'
this.status = status
this.errorType = errorType
}
}
export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
function debug(msg: string): void {
deps.onDebug?.(msg)
}
let consecutiveEmptyPolls = 0
const EMPTY_POLL_LOG_INTERVAL = 100
function getHeaders(accessToken: string): Record<string, string> {
const headers: Record<string, string> = {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': '2023-06-01',
'anthropic-beta': BETA_HEADER,
'x-environment-runner-version': deps.runnerVersion,
}
const deviceToken = deps.getTrustedDeviceToken?.()
if (deviceToken) {
headers['X-Trusted-Device-Token'] = deviceToken
}
return headers
}
function resolveAuth(): string {
const accessToken = deps.getAccessToken()
if (!accessToken) {
throw new Error(BRIDGE_LOGIN_INSTRUCTION)
}
return accessToken
}
/**
* Execute an OAuth-authenticated request with a single retry on 401.
* On 401, attempts token refresh via handleOAuth401Error (same pattern as
* withRetry.ts for v1/messages). If refresh succeeds, retries the request
* once with the new token. If refresh fails or the retry also returns 401,
* the 401 response is returned for handleErrorStatus to throw BridgeFatalError.
* 执行 OAuth 认证的请求,在 401 时单次重试。
* 在 401 时,通过 handleOAuth401Error 尝试 token 刷新(与
* withRetry.ts for v1/messages 相同的模式)。如果刷新成功,
* 使用新 token 重试一次请求。如果刷新失败或重试也返回 401
* 返回 401 响应给 handleErrorStatus 抛出 BridgeFatalError。
*/
async function withOAuthRetry<T>(
fn: (accessToken: string) => Promise<{ status: number; data: T }>,
context: string,
): Promise<{ status: number; data: T }> {
const accessToken = resolveAuth()
const response = await fn(accessToken)
if (response.status !== 401) {
return response
}
if (!deps.onAuth401) {
debug(`[bridge:api] ${context}: 401 received, no refresh handler`)
return response
}
// Attempt token refresh — matches the pattern in withRetry.ts
// 尝试 token 刷新 — 与 withRetry.ts 中的模式匹配
debug(`[bridge:api] ${context}: 401 received, attempting token refresh`)
const refreshed = await deps.onAuth401(accessToken)
if (refreshed) {
debug(`[bridge:api] ${context}: Token refreshed, retrying request`)
const newToken = resolveAuth()
const retryResponse = await fn(newToken)
if (retryResponse.status !== 401) {
return retryResponse
}
debug(`[bridge:api] ${context}: Retry after refresh also got 401`)
} else {
debug(`[bridge:api] ${context}: Token refresh failed`)
}
// Refresh failed — return 401 for handleErrorStatus to throw
// 刷新失败 — 返回 401 给 handleErrorStatus 抛出
return response
}
return {
async registerBridgeEnvironment(
config: BridgeConfig,
): Promise<{ environment_id: string; environment_secret: string }> {
debug(
`[bridge:api] POST /v1/environments/bridge bridgeId=${config.bridgeId}`,
)
const response = await withOAuthRetry(
(token: string) =>
axios.post<{
environment_id: string
environment_secret: string
}>(
`${deps.baseUrl}/v1/environments/bridge`,
{
machine_name: config.machineName,
directory: config.dir,
branch: config.branch,
git_repo_url: config.gitRepoUrl,
// Advertise session capacity so claude.ai/code can show
// "2/4 sessions" badges and only block the picker when
// actually at capacity. Backends that don't yet accept
// this field will silently ignore it.
// 广告会话容量以便 claude.ai/code 可以显示
// "2/4 sessions" 徽章,仅在实际满容量时阻止选择器。
// 尚不接受此字段的后端将静默忽略它。
max_sessions: config.maxSessions,
// worker_type lets claude.ai filter environments by origin
// (e.g. assistant picker only shows assistant-mode workers).
// Desktop cowork app sends "cowork"; we send a distinct value.
// worker_type 让 claude.ai 按来源过滤环境
//(例如 assistant picker 只显示 assistant-mode workers
// Desktop cowork app 发送 "cowork";我们发送一个不同的值。
metadata: { worker_type: config.workerType },
// Idempotent re-registration: if we have a backend-issued
// environment_id from a prior session (--session-id resume),
// send it back so the backend reattaches instead of creating
// a new env. The backend may still hand back a fresh ID if
// the old one expired — callers must compare the response.
// 幂等重新注册:如果我们有来自先前会话的 backend 发行的
// environment_id--session-id resume
// 发送回去以便 backend 重新附加而非创建新 env。
// 如果旧的过期了backend 仍可能发回新 ID — 调用者必须比较响应。
...(config.reuseEnvironmentId && {
environment_id: config.reuseEnvironmentId,
}),
},
{
headers: getHeaders(token),
timeout: 15_000,
validateStatus: status => status < 500,
},
),
'Registration',
)
handleErrorStatus(response.status, response.data, 'Registration')
debug(
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
)
debug(
`[bridge:api] >>> ${debugBody({ machine_name: config.machineName, directory: config.dir, branch: config.branch, git_repo_url: config.gitRepoUrl, max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
return response.data
},
async pollForWork(
environmentId: string,
environmentSecret: string,
signal?: AbortSignal,
reclaimOlderThanMs?: number,
): Promise<WorkResponse | null> {
validateBridgeId(environmentId, 'environmentId')
// Save and reset so errors break the "consecutive empty" streak.
// Restored below when the response is truly empty.
// 保存并重置,以便错误打破"连续空"的连续记录。
// 当响应真正为空时在下面恢复。
const prevEmptyPolls = consecutiveEmptyPolls
consecutiveEmptyPolls = 0
const response = await axios.get<WorkResponse | null>(
`${deps.baseUrl}/v1/environments/${environmentId}/work/poll`,
{
headers: getHeaders(environmentSecret),
params:
reclaimOlderThanMs !== undefined
? { reclaim_older_than_ms: reclaimOlderThanMs }
: undefined,
timeout: 10_000,
signal,
validateStatus: status => status < 500,
},
)
handleErrorStatus(response.status, response.data, 'Poll')
// Empty body or null = no work available
// 空 body 或 null = 没有可用工作
if (!response.data) {
consecutiveEmptyPolls = prevEmptyPolls + 1
if (
consecutiveEmptyPolls === 1 ||
consecutiveEmptyPolls % EMPTY_POLL_LOG_INTERVAL === 0
) {
debug(
`[bridge:api] GET .../work/poll -> ${response.status} (no work, ${consecutiveEmptyPolls} consecutive empty polls)`,
)
}
return null
}
debug(
`[bridge:api] GET .../work/poll -> ${response.status} workId=${response.data.id} type=${response.data.data?.type}${response.data.data?.id ? ` sessionId=${response.data.data.id}` : ''}`,
)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
return response.data
},
async acknowledgeWork(
environmentId: string,
workId: string,
sessionToken: string,
): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(workId, 'workId')
debug(`[bridge:api] POST .../work/${workId}/ack`)
const response = await axios.post(
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/ack`,
{},
{
headers: getHeaders(sessionToken),
timeout: 10_000,
validateStatus: s => s < 500,
},
)
handleErrorStatus(response.status, response.data, 'Acknowledge')
debug(`[bridge:api] POST .../work/${workId}/ack -> ${response.status}`)
},
async stopWork(
environmentId: string,
workId: string,
force: boolean,
): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(workId, 'workId')
debug(`[bridge:api] POST .../work/${workId}/stop force=${force}`)
const response = await withOAuthRetry(
(token: string) =>
axios.post(
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/stop`,
{ force },
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'StopWork',
)
handleErrorStatus(response.status, response.data, 'StopWork')
debug(`[bridge:api] POST .../work/${workId}/stop -> ${response.status}`)
},
async deregisterEnvironment(environmentId: string): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
debug(`[bridge:api] DELETE /v1/environments/bridge/${environmentId}`)
const response = await withOAuthRetry(
(token: string) =>
axios.delete(
`${deps.baseUrl}/v1/environments/bridge/${environmentId}`,
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'Deregister',
)
handleErrorStatus(response.status, response.data, 'Deregister')
debug(
`[bridge:api] DELETE /v1/environments/bridge/${environmentId} -> ${response.status}`,
)
},
async archiveSession(sessionId: string): Promise<void> {
validateBridgeId(sessionId, 'sessionId')
debug(`[bridge:api] POST /v1/sessions/${sessionId}/archive`)
const response = await withOAuthRetry(
(token: string) =>
axios.post(
`${deps.baseUrl}/v1/sessions/${sessionId}/archive`,
{},
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'ArchiveSession',
)
// 409 = already archived (idempotent, not an error)
// 409 = 已归档(幂等,不是错误)
if (response.status === 409) {
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> 409 (already archived)`,
)
return
}
handleErrorStatus(response.status, response.data, 'ArchiveSession')
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/archive -> ${response.status}`,
)
},
async reconnectSession(
environmentId: string,
sessionId: string,
): Promise<void> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(sessionId, 'sessionId')
debug(
`[bridge:api] POST /v1/environments/${environmentId}/bridge/reconnect session_id=${sessionId}`,
)
const response = await withOAuthRetry(
(token: string) =>
axios.post(
`${deps.baseUrl}/v1/environments/${environmentId}/bridge/reconnect`,
{ session_id: sessionId },
{
headers: getHeaders(token),
timeout: 10_000,
validateStatus: s => s < 500,
},
),
'ReconnectSession',
)
handleErrorStatus(response.status, response.data, 'ReconnectSession')
debug(`[bridge:api] POST .../bridge/reconnect -> ${response.status}`)
},
async heartbeatWork(
environmentId: string,
workId: string,
sessionToken: string,
): Promise<{ lease_extended: boolean; state: string }> {
validateBridgeId(environmentId, 'environmentId')
validateBridgeId(workId, 'workId')
debug(`[bridge:api] POST .../work/${workId}/heartbeat`)
const response = await axios.post<{
lease_extended: boolean
state: string
last_heartbeat: string
ttl_seconds: number
}>(
`${deps.baseUrl}/v1/environments/${environmentId}/work/${workId}/heartbeat`,
{},
{
headers: getHeaders(sessionToken),
timeout: 10_000,
validateStatus: s => s < 500,
},
)
handleErrorStatus(response.status, response.data, 'Heartbeat')
debug(
`[bridge:api] POST .../work/${workId}/heartbeat -> ${response.status} lease_extended=${response.data.lease_extended} state=${response.data.state}`,
)
return response.data
},
async sendPermissionResponseEvent(
sessionId: string,
event: PermissionResponseEvent,
sessionToken: string,
): Promise<void> {
validateBridgeId(sessionId, 'sessionId')
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/events type=${event.type}`,
)
const response = await axios.post(
`${deps.baseUrl}/v1/sessions/${sessionId}/events`,
{ events: [event] },
{
headers: getHeaders(sessionToken),
timeout: 10_000,
validateStatus: s => s < 500,
},
)
handleErrorStatus(
response.status,
response.data,
'SendPermissionResponseEvent',
)
debug(
`[bridge:api] POST /v1/sessions/${sessionId}/events -> ${response.status}`,
)
debug(`[bridge:api] >>> ${debugBody({ events: [event] })}`)
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
},
}
}
function handleErrorStatus(
status: number,
data: unknown,
context: string,
): void {
if (status === 200 || status === 204) {
return
}
const detail = extractErrorDetail(data)
const errorType = extractErrorTypeFromData(data)
switch (status) {
case 401:
throw new BridgeFatalError(
`${context}: Authentication failed (401)${detail ? `: ${detail}` : ''}. ${BRIDGE_LOGIN_INSTRUCTION}`,
401,
errorType,
)
case 403:
throw new BridgeFatalError(
isExpiredErrorType(errorType)
? 'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.'
: `${context}: Access denied (403)${detail ? `: ${detail}` : ''}. Check your organization permissions.`,
403,
errorType,
)
case 404:
throw new BridgeFatalError(
detail ??
`${context}: Not found (404). Remote Control may not be available for this organization.`,
404,
errorType,
)
case 410:
throw new BridgeFatalError(
detail ??
'Remote Control session has expired. Please restart with `claude remote-control` or /remote-control.',
410,
errorType ?? 'environment_expired',
)
case 429:
throw new Error(`${context}: Rate limited (429). Polling too frequently.`)
default:
throw new Error(
`${context}: Failed with status ${status}${detail ? `: ${detail}` : ''}`,
)
}
}
/** Check whether an error type string indicates a session/environment expiry. */
/** 检查错误类型字符串是否表示会话/环境过期。 */
export function isExpiredErrorType(errorType: string | undefined): boolean {
if (!errorType) {
return false
}
return errorType.includes('expired') || errorType.includes('lifetime')
}
/**
* Check whether a BridgeFatalError is a suppressible 403 permission error.
* These are 403 errors for scopes like 'external_poll_sessions' or operations
* like StopWork that fail because the user's role lacks 'environments:manage'.
* They don't affect core functionality and shouldn't be shown to users.
*/
/**
* 检查 BridgeFatalError 是否为可抑制的 403 权限错误。
* 这些是对于诸如 'external_poll_sessions' 范围或诸如 StopWork 的操作
* 的 403 错误,这些操作失败是因为用户的角色缺少 'environments:manage'。
* 它们不影响核心功能,不应该显示给用户。
*/
export function isSuppressible403(err: BridgeFatalError): boolean {
if (err.status !== 403) {
return false
}
return (
err.message.includes('external_poll_sessions') ||
err.message.includes('environments:manage')
)
}
function extractErrorTypeFromData(data: unknown): string | undefined {
if (data && typeof data === 'object') {
if (
'error' in data &&
data.error &&
typeof data.error === 'object' &&
'type' in data.error &&
typeof data.error.type === 'string'
) {
return data.error.type
}
}
return undefined
}

View File

@@ -0,0 +1,57 @@
/**
* Shared bridge auth/URL resolution. Consolidates the ant-only
* CLAUDE_BRIDGE_* dev overrides that were previously copy-pasted across
* a dozen files — inboundAttachments, BriefTool/upload, bridgeMain,
* initReplBridge, remoteBridgeCore, daemon workers, /rename,
* /remote-control.
*
* Two layers: *Override() returns the ant-only env var (or undefined);
* the non-Override versions fall through to the real OAuth store/config.
* Callers that compose with a different auth source (e.g. daemon workers
* using IPC auth) use the Override getters directly.
* 共享 bridge auth/URL 解析。整合了之前跨十几个文件复制粘贴的
* ant 专用 CLAUDE_BRIDGE_* dev 覆盖 — inboundAttachments、BriefTool/upload、
* bridgeMain、initReplBridge、remoteBridgeCore、daemon workers、/rename、
* /remote-control。
*
* 两层:*Override() 返回 ant 专用 env var或 undefined
* 非 Override 版本回退到真正的 OAuth store/config。
* 使用不同 auth 源的调用者(例如使用 IPC auth 的 daemon workers
* 直接使用 Override getters。
*/
import { getOauthConfig } from '../constants/oauth.js'
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
export function getBridgeTokenOverride(): string | undefined {
return (
(process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_OAUTH_TOKEN) ||
undefined
)
}
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
export function getBridgeBaseUrlOverride(): string | undefined {
return (
(process.env.USER_TYPE === 'ant' && process.env.CLAUDE_BRIDGE_BASE_URL) ||
undefined
)
}
/**
* Access token for bridge API calls: dev override first, then the OAuth
* keychain. Undefined means "not logged in".
*/
export function getBridgeAccessToken(): string | undefined {
return getBridgeTokenOverride() ?? getClaudeAIOAuthTokens()?.accessToken
}
/**
* Base URL for bridge API calls: dev override first, then the production
* OAuth config. Always returns a URL.
*/
export function getBridgeBaseUrl(): string {
return getBridgeBaseUrlOverride() ?? getOauthConfig().BASE_API_URL
}

View File

@@ -0,0 +1,135 @@
import { logForDebugging } from '../utils/debug.js'
import { BridgeFatalError } from './bridgeApi.js'
import type { BridgeApiClient } from './types.js'
/**
* Ant 专用的故障注入,用于手动测试 bridge 恢复路径。
*
* 目标真实故障模式BQ 2026-03-127 天窗口):
* poll 404 not_found_error — 147K sessions/weekdead onEnvironmentLost gate
* ws_closed 1002/1006 — 22K sessions/week关闭后僵尸轮询
* register transient failure — 残留doReconnect 期间网络抖动
*
* 用法:在 Remote Control 连接的 REPL 中从 /bridge-kick <subcommand>
* 然后 tail debug.log 观察恢复机制的反应。
*
* 模块级状态在此是有意的:一个 REPL 进程一个 bridge
* /bridge-kick 斜杠命令没有其他方式进入 initBridgeCore 的闭包,
* 拆除清除槽位。
*/
/** 对下一次匹配 API 调用的一次性故障注入。 */
type BridgeFault = {
method:
| 'pollForWork'
| 'registerBridgeEnvironment'
| 'reconnectSession'
| 'heartbeatWork'
/** 致命错误通过 handleErrorStatus → BridgeFatalError。瞬态
* 错误显示为普通 axios 拒绝5xx / network。恢复代码
* 区分两者:致命 → 拆除,瞬态 → 重试/退避。 */
kind: 'fatal' | 'transient'
status: number
errorType?: string
/** 剩余注入次数。消耗时递减;为 0 时移除。 */
count: number
}
export type BridgeDebugHandle = {
/** 直接调用传输的永久关闭处理程序。测试
* ws_closed → reconnectEnvironmentWithSession 升级(#22148。 */
fireClose: (code: number) => void
/** 调用 reconnectEnvironmentWithSession() — 与 SIGUSR2 相同但
* 可从斜杠命令触达。 */
forceReconnect: () => void
/** 为下一次对命名 API 方法的 N 次调用排队故障。 */
injectFault: (fault: BridgeFault) => void
/** 中止容量睡眠,使注入的轮询故障立即落地,
* 而不是最多 10 分钟后。 */
wakePollLoop: () => void
/** 用于 debug.log grep 的 env/session ID。 */
describe: () => string
}
let debugHandle: BridgeDebugHandle | null = null
const faultQueue: BridgeFault[] = []
export function registerBridgeDebugHandle(h: BridgeDebugHandle): void {
debugHandle = h
}
export function clearBridgeDebugHandle(): void {
debugHandle = null
faultQueue.length = 0
}
export function getBridgeDebugHandle(): BridgeDebugHandle | null {
return debugHandle
}
export function injectBridgeFault(fault: BridgeFault): void {
faultQueue.push(fault)
logForDebugging(
`[bridge:debug] Queued fault: ${fault.method} ${fault.kind}/${fault.status}${fault.errorType ? `/${fault.errorType}` : ''} ×${fault.count}`,
)
}
/**
* 包装 BridgeApiClient 以便每次调用首先检查故障队列。如果
* 匹配的故障已排队,则抛出指定错误而不是调用
* 通过。委托其余所有内容到真实客户端。
*
* 仅在 USER_TYPE === 'ant' 时调用 — 外部构建零开销。
*/
export function wrapApiForFaultInjection(
api: BridgeApiClient,
): BridgeApiClient {
function consume(method: BridgeFault['method']): BridgeFault | null {
const idx = faultQueue.findIndex(f => f.method === method)
if (idx === -1) return null
const fault = faultQueue[idx]!
fault.count--
if (fault.count <= 0) faultQueue.splice(idx, 1)
return fault
}
function throwFault(fault: BridgeFault, context: string): never {
logForDebugging(
`[bridge:debug] Injecting ${fault.kind} fault into ${context}: status=${fault.status} errorType=${fault.errorType ?? 'none'}`,
)
if (fault.kind === 'fatal') {
throw new BridgeFatalError(
`[injected] ${context} ${fault.status}`,
fault.status,
fault.errorType,
)
}
// Transient: 模拟 axios 拒绝5xx / network。错误本身没有 .status —
// 这是 catch 块区分的方式。
throw new Error(`[injected transient] ${context} ${fault.status}`)
}
return {
...api,
async pollForWork(envId, secret, signal, reclaimMs) {
const f = consume('pollForWork')
if (f) throwFault(f, 'Poll')
return api.pollForWork(envId, secret, signal, reclaimMs)
},
async registerBridgeEnvironment(config) {
const f = consume('registerBridgeEnvironment')
if (f) throwFault(f, 'Registration')
return api.registerBridgeEnvironment(config)
},
async reconnectSession(envId, sessionId) {
const f = consume('reconnectSession')
if (f) throwFault(f, 'ReconnectSession')
return api.reconnectSession(envId, sessionId)
},
async heartbeatWork(envId, workId, token) {
const f = consume('heartbeatWork')
if (f) throwFault(f, 'Heartbeat')
return api.heartbeatWork(envId, workId, token)
},
}
}

View File

@@ -0,0 +1,195 @@
import {
getClaudeAiBaseUrl,
getRemoteSessionUrl,
} from '../constants/product.js'
import { stringWidth } from '../ink/stringWidth.js'
import { formatDuration, truncateToWidth } from '../utils/format.js'
import { getGraphemeSegmenter } from '../utils/intl.js'
/** Bridge status state machine states. */
/** Bridge 状态机状态。 */
export type StatusState =
| 'idle'
| 'attached'
| 'titled'
| 'reconnecting'
| 'failed'
/** How long a tool activity line stays visible after last tool_start (ms). */
/** 工具活动行在上次 tool_start 后保持可见的时长(毫秒)。 */
export const TOOL_DISPLAY_EXPIRY_MS = 30_000
/** Interval for the shimmer animation tick (ms). */
/** 微光动画 tick 的间隔(毫秒)。 */
export const SHIMMER_INTERVAL_MS = 150
export function timestamp(): string {
const now = new Date()
const h = String(now.getHours()).padStart(2, '0')
const m = String(now.getMinutes()).padStart(2, '0')
const s = String(now.getSeconds()).padStart(2, '0')
return `${h}:${m}:${s}`
}
export { formatDuration, truncateToWidth as truncatePrompt }
/** Abbreviate a tool activity summary for the trail display. */
/** 为 trail 显示缩写工具活动摘要。 */
export function abbreviateActivity(summary: string): string {
return truncateToWidth(summary, 30)
}
/** Build the connect URL shown when the bridge is idle. */
/** 构建 bridge 空闲时显示的连接 URL。 */
export function buildBridgeConnectUrl(
environmentId: string,
ingressUrl?: string,
): string {
const baseUrl = getClaudeAiBaseUrl(undefined, ingressUrl)
return `${baseUrl}/code?bridge=${environmentId}`
}
/**
* Build the session URL shown when a session is attached. Delegates to
* getRemoteSessionUrl for the cse_→session_ prefix translation, then appends
* the v1-specific ?bridge={environmentId} query.
*/
/**
* 构建会话附加时显示的会话 URL。委托给
* getRemoteSessionUrl 进行 cse_→session_ 前缀转换,然后附加
* v1 特定的 ?bridge={environmentId} 查询。
*/
export function buildBridgeSessionUrl(
sessionId: string,
environmentId: string,
ingressUrl?: string,
): string {
return `${getRemoteSessionUrl(sessionId, ingressUrl)}?bridge=${environmentId}`
}
/** Compute the glimmer index for a reverse-sweep shimmer animation. */
/** 计算反向扫描微光动画的 glimmer 索引。 */
export function computeGlimmerIndex(
tick: number,
messageWidth: number,
): number {
const cycleLength = messageWidth + 20
return messageWidth + 10 - (tick % cycleLength)
}
/**
* Split text into three segments by visual column position for shimmer rendering.
*
* Uses grapheme segmentation and `stringWidth` so the split is correct for
* multi-byte characters, emoji, and CJK glyphs.
*
* Returns `{ before, shimmer, after }` strings. Both renderers (chalk in
* bridgeUI.ts and React/Ink in bridge.tsx) apply their own coloring to
* these segments.
*/
/**
* 通过视觉列位置将文本分割成三段用于微光渲染。
*
* 使用字素分割和 `stringWidth`以便对多字节字符、emoji
* 和 CJK 字形的分割正确。
*
* 返回 `{ before, shimmer, after }` 字符串。两个渲染器bridgeUI.ts 中的 chalk
* 和 bridge.tsx 中的 React/Ink对这些段应用自己的着色。
*/
export function computeShimmerSegments(
text: string,
glimmerIndex: number,
): { before: string; shimmer: string; after: string } {
const messageWidth = stringWidth(text)
const shimmerStart = glimmerIndex - 1
const shimmerEnd = glimmerIndex + 1
// When shimmer is offscreen, return all text as "before"
// 当微光在屏幕外时,将所有文本作为 "before" 返回
if (shimmerStart >= messageWidth || shimmerEnd < 0) {
return { before: text, shimmer: '', after: '' }
}
// Split into at most 3 segments by visual column position
// 通过视觉列位置分割成最多 3 段
const clampedStart = Math.max(0, shimmerStart)
let colPos = 0
let before = ''
let shimmer = ''
let after = ''
for (const { segment } of getGraphemeSegmenter().segment(text)) {
const segWidth = stringWidth(segment)
if (colPos + segWidth <= clampedStart) {
before += segment
} else if (colPos > shimmerEnd) {
after += segment
} else {
shimmer += segment
}
colPos += segWidth
}
return { before, shimmer, after }
}
/** Computed bridge status label and color from connection state. */
/** 从连接状态计算 bridge 状态标签和颜色。 */
export type BridgeStatusInfo = {
label:
| 'Remote Control failed'
| 'Remote Control reconnecting'
| 'Remote Control active'
| 'Remote Control connecting\u2026'
color: 'error' | 'warning' | 'success'
}
/** Derive a status label and color from the bridge connection state. */
/** 从 bridge 连接状态派生状态标签和颜色。 */
export function getBridgeStatus({
error,
connected,
sessionActive,
reconnecting,
}: {
error: string | undefined
connected: boolean
sessionActive: boolean
reconnecting: boolean
}): BridgeStatusInfo {
if (error) return { label: 'Remote Control failed', color: 'error' }
if (reconnecting)
return { label: 'Remote Control reconnecting', color: 'warning' }
if (sessionActive || connected)
return { label: 'Remote Control active', color: 'success' }
return { label: 'Remote Control connecting\u2026', color: 'warning' }
}
/** Footer text shown when bridge is idle (Ready state). */
/** Bridge 空闲时Ready 状态)显示的页脚文本。 */
export function buildIdleFooterText(url: string): string {
return `Code everywhere with the Claude app or ${url}`
}
/** Footer text shown when a session is active (Connected state). */
/** 会话处于活动状态时Connected 状态)显示的页脚文本。 */
export function buildActiveFooterText(url: string): string {
return `Continue coding in the Claude app or ${url}`
}
/** Footer text shown when the bridge has failed. */
/** Bridge 失败时显示的页脚文本。 */
export const FAILED_FOOTER_TEXT = 'Something went wrong, please try again'
/**
* Wrap text in an OSC 8 terminal hyperlink. Zero visual width for layout purposes.
* strip-ansi (used by stringWidth) correctly strips these sequences, so
* countVisualLines in bridgeUI.ts remains accurate.
*/
/**
* 在 OSC 8 终端超链接中包装文本。布局目的的零视觉宽度。
* strip-ansi用于 stringWidth正确地剥离这些序列
* 因此 bridgeUI.ts 中的 countVisualLines 保持准确。
*/
export function wrapWithOsc8Link(text: string, url: string): string {
return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`
}

View File

@@ -0,0 +1,181 @@
/**
* Thin HTTP wrappers for the CCR v2 code-session API.
*
* Separate file from remoteBridgeCore.ts so the SDK /bridge subpath can
* export createCodeSession + fetchRemoteCredentials without bundling the
* heavy CLI tree (analytics, transport, etc.). Callers supply explicit
* accessToken + baseUrl — no implicit auth or config reads.
* CCR v2 code-session API 的精简 HTTP 包装器。
*
* 与 remoteBridgeCore.ts 分开的文件,以便 SDK /bridge 子路径可以
* 导出 createCodeSession + fetchRemoteCredentials 而不捆绑
* 重型 CLI 树analytics、transport 等)。调用者提供显式的
* accessToken + baseUrl — 没有隐式 auth 或 config 读取。
*/
import axios from 'axios'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { jsonStringify } from '../utils/slowOperations.js'
import { extractErrorDetail } from './debugUtils.js'
const ANTHROPIC_VERSION = '2023-06-01'
function oauthHeaders(accessToken: string): Record<string, string> {
return {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json',
'anthropic-version': ANTHROPIC_VERSION,
}
}
export async function createCodeSession(
baseUrl: string,
accessToken: string,
title: string,
timeoutMs: number,
tags?: string[],
): Promise<string | null> {
const url = `${baseUrl}/v1/code/sessions`
let response
try {
response = await axios.post(
url,
// bridge: {} is the positive signal for the oneof runner — omitting it
// (or sending environment_id: "") now 400s. BridgeRunner is an empty
// message today; it's a placeholder for future bridge-specific options.
// bridge: {} 是 oneof runner 的 positive signal — 现在省略它
//(或发送 environment_id: "")会 400。BridgeRunner 今天是一个空消息;
// 它是未来 bridge 特定选项的占位符。
{ title, bridge: {}, ...(tags?.length ? { tags } : {}) },
{
headers: oauthHeaders(accessToken),
timeout: timeoutMs,
validateStatus: s => s < 500,
},
)
} catch (err: unknown) {
logForDebugging(
`[code-session] Session create request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200 && response.status !== 201) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[code-session] Session create failed ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
const data: unknown = response.data
if (
!data ||
typeof data !== 'object' ||
!('session' in data) ||
!data.session ||
typeof data.session !== 'object' ||
!('id' in data.session) ||
typeof data.session.id !== 'string' ||
!data.session.id.startsWith('cse_')
) {
logForDebugging(
`[code-session] No session.id (cse_*) in response: ${jsonStringify(data).slice(0, 200)}`,
)
return null
}
return data.session.id
}
/**
* Credentials from POST /bridge. JWT is opaque — do not decode.
* Each /bridge call bumps worker_epoch server-side (it IS the register).
* 来自 POST /bridge 的凭证。JWT 是不透明的 — 不要解码。
* 每次 /bridge 调用都会在服务器端增加 worker_epoch它就是注册
*/
export type RemoteCredentials = {
worker_jwt: string
api_base_url: string
expires_in: number
worker_epoch: number
}
export async function fetchRemoteCredentials(
sessionId: string,
baseUrl: string,
accessToken: string,
timeoutMs: number,
trustedDeviceToken?: string,
): Promise<RemoteCredentials | null> {
const url = `${baseUrl}/v1/code/sessions/${sessionId}/bridge`
const headers = oauthHeaders(accessToken)
if (trustedDeviceToken) {
headers['X-Trusted-Device-Token'] = trustedDeviceToken
}
let response
try {
response = await axios.post(
url,
{},
{
headers,
timeout: timeoutMs,
validateStatus: s => s < 500,
},
)
} catch (err: unknown) {
logForDebugging(
`[code-session] /bridge request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[code-session] /bridge failed ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
const data: unknown = response.data
if (
data === null ||
typeof data !== 'object' ||
!('worker_jwt' in data) ||
typeof data.worker_jwt !== 'string' ||
!('expires_in' in data) ||
typeof data.expires_in !== 'number' ||
!('api_base_url' in data) ||
typeof data.api_base_url !== 'string' ||
!('worker_epoch' in data)
) {
logForDebugging(
`[code-session] /bridge response malformed (need worker_jwt, expires_in, api_base_url, worker_epoch): ${jsonStringify(data).slice(0, 200)}`,
)
return null
}
// protojson serializes int64 as a string to avoid JS precision loss;
// Go may also return a number depending on encoder settings.
// protojson 将 int64 序列化为字符串以避免 JS 精度损失;
// Go 也可以根据编码器设置返回数字。
const rawEpoch = data.worker_epoch
const epoch = typeof rawEpoch === 'string' ? Number(rawEpoch) : rawEpoch
if (
typeof epoch !== 'number' ||
!Number.isFinite(epoch) ||
!Number.isSafeInteger(epoch)
) {
logForDebugging(
`[code-session] /bridge worker_epoch invalid: ${jsonStringify(rawEpoch)}`,
)
return null
}
return {
worker_jwt: data.worker_jwt,
api_base_url: data.api_base_url,
expires_in: data.expires_in,
worker_epoch: epoch,
}
}

View File

@@ -0,0 +1,382 @@
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { extractErrorDetail } from './debugUtils.js'
import { toCompatSessionId } from './sessionIdCompat.js'
type GitSource = {
type: 'git_repository'
url: string
revision?: string
}
type GitOutcome = {
type: 'git_repository'
git_info: { type: 'github'; repo: string; branches: string[] }
}
// 事件必须包装在 { type: 'event', data: <sdk_message> } 中用于
// POST /v1/sessions 端点(可识别联合格式)。
type SessionEvent = {
type: 'event'
data: SDKMessage
}
/**
* 通过 POST /v1/sessions 在 bridge 环境中创建会话。
*
* 由 `claude remote-control`(空会话以便用户可以立即输入)
* 和 `/remote-control`(预填充对话历史的会话)使用。
*
* 成功时返回会话 ID创建失败时返回 null非致命
*/
export async function createBridgeSession({
environmentId,
title,
events,
gitRepoUrl,
branch,
signal,
baseUrl: baseUrlOverride,
getAccessToken,
permissionMode,
}: {
environmentId: string
title?: string
events: SessionEvent[]
gitRepoUrl: string | null
branch: string
signal: AbortSignal
baseUrl?: string
getAccessToken?: () => string | undefined
permissionMode?: string
}): Promise<string | null> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
const { getDefaultBranch } = await import('../utils/git.js')
const { getMainLoopModel } = await import('../utils/model/model.js')
const { default: axios } = await import('axios')
const accessToken =
getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session creation')
return null
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session creation')
return null
}
// 构建 git source 和 outcome 上下文
let gitSource: GitSource | null = null
let gitOutcome: GitOutcome | null = null
if (gitRepoUrl) {
const { parseGitRemote } = await import('../utils/detectRepository.js')
const parsed = parseGitRemote(gitRepoUrl)
if (parsed) {
const { host, owner, name } = parsed
const revision = branch || (await getDefaultBranch()) || undefined
gitSource = {
type: 'git_repository',
url: `https://${host}/${owner}/${name}`,
revision,
}
gitOutcome = {
type: 'git_repository',
git_info: {
type: 'github',
repo: `${owner}/${name}`,
branches: [`claude/${branch || 'task'}`],
},
}
} else {
// 后备:尝试 parseGitHubRepository 用于 owner/repo 格式
const ownerRepo = parseGitHubRepository(gitRepoUrl)
if (ownerRepo) {
const [owner, name] = ownerRepo.split('/')
if (owner && name) {
const revision = branch || (await getDefaultBranch()) || undefined
gitSource = {
type: 'git_repository',
url: `https://github.com/${owner}/${name}`,
revision,
}
gitOutcome = {
type: 'git_repository',
git_info: {
type: 'github',
repo: `${owner}/${name}`,
branches: [`claude/${branch || 'task'}`],
},
}
}
}
}
}
const requestBody = {
...(title !== undefined && { title }),
events,
session_context: {
sources: gitSource ? [gitSource] : [],
outcomes: gitOutcome ? [gitOutcome] : [],
model: getMainLoopModel(),
},
environment_id: environmentId,
source: 'remote-control',
...(permissionMode && { permission_mode: permissionMode }),
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
const url = `${baseUrlOverride ?? getOauthConfig().BASE_API_URL}/v1/sessions`
let response
try {
response = await axios.post(url, requestBody, {
headers,
signal,
validateStatus: s => s < 500,
})
} catch (err: unknown) {
logForDebugging(
`[bridge] Session creation request failed: ${errorMessage(err)}`,
)
return null
}
const isSuccess = response.status === 200 || response.status === 201
if (!isSuccess) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session creation failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
const sessionData: unknown = response.data
if (
!sessionData ||
typeof sessionData !== 'object' ||
!('id' in sessionData) ||
typeof sessionData.id !== 'string'
) {
logForDebugging('[bridge] No session ID in response')
return null
}
return sessionData.id
}
/**
* 通过 GET /v1/sessions/{id} 获取 bridge 会话。
*
* 返回会话的 environment_id用于 `--session-id` 恢复)和标题。
* 使用与 create/archive 相同的 org-scoped headers — environments 级
* bridgeApi.ts 中的客户端使用不同的 beta header 和没有 org UUID
* 这会使 Sessions API 返回 404。
*/
export async function getBridgeSession(
sessionId: string,
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
): Promise<{ environment_id?: string; title?: string } | null> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { default: axios } = await import('axios')
const accessToken =
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session fetch')
return null
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session fetch')
return null
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}`
logForDebugging(`[bridge] Fetching session ${sessionId}`)
let response
try {
response = await axios.get<{ environment_id?: string; title?: string }>(
url,
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
)
} catch (err: unknown) {
logForDebugging(
`[bridge] Session fetch request failed: ${errorMessage(err)}`,
)
return null
}
if (response.status !== 200) {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session fetch failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
return null
}
return response.data
}
/**
* 通过 POST /v1/sessions/{id}/archive 归档 bridge 会话。
*
* CCR 服务器从不自动归档会话 — 归档始终是
* 明确的客户端操作。`claude remote-control`(独立 bridge
* 始终开启的 `/remote-control` REPL bridge 都在关闭期间调用此方法
* 以归档任何仍在活动的会话。
*
* 归档端点接受任何状态的会话running、idle、
* requires_action、pending如果已归档则返回 409因此
* 即使服务器端运行者已经归档了会话,调用它也是安全的。
*
* 调用者必须处理错误 — 此函数没有 try/catch5xx、
* 超时和网络错误会抛出。归档在清理期间是尽力而为的;
* 调用点用 .catch() 包装。
*/
export async function archiveBridgeSession(
sessionId: string,
opts?: {
baseUrl?: string
getAccessToken?: () => string | undefined
timeoutMs?: number
},
): Promise<void> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { default: axios } = await import('axios')
const accessToken =
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session archive')
return
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session archive')
return
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/archive`
logForDebugging(`[bridge] Archiving session ${sessionId}`)
const response = await axios.post(
url,
{},
{
headers,
timeout: opts?.timeoutMs ?? 10_000,
validateStatus: s => s < 500,
},
)
if (response.status === 200) {
logForDebugging(`[bridge] Session ${sessionId} archived successfully`)
} else {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session archive failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
}
}
/**
* 通过 PATCH /v1/sessions/{id} 更新 bridge 会话的标题。
*
* 当用户通过 /rename 重命名会话而 bridge
* 连接处于活动状态时调用,以使标题在 claude.ai/code 上保持同步。
*
* 错误被吞没 — 标题同步是尽力而为的。
*/
export async function updateBridgeSessionTitle(
sessionId: string,
title: string,
opts?: { baseUrl?: string; getAccessToken?: () => string | undefined },
): Promise<void> {
const { getClaudeAIOAuthTokens } = await import('../utils/auth.js')
const { getOrganizationUUID } = await import('../services/oauth/client.js')
const { getOauthConfig } = await import('../constants/oauth.js')
const { getOAuthHeaders } = await import('../utils/teleport/api.js')
const { default: axios } = await import('axios')
const accessToken =
opts?.getAccessToken?.() ?? getClaudeAIOAuthTokens()?.accessToken
if (!accessToken) {
logForDebugging('[bridge] No access token for session title update')
return
}
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logForDebugging('[bridge] No org UUID for session title update')
return
}
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': 'ccr-byoc-2025-07-29',
'x-organization-uuid': orgUUID,
}
// 兼容网关只接受 session_*compat/convert.go:27。v2 调用者
// 传递原始 cse_*;在此重新标记,以便所有调用者可以传递它们持有的任何内容。
// 对 v1 的 session_* 和 bridgeMain 的预转换 compatSessionId 是幂等的。
const compatId = toCompatSessionId(sessionId)
const url = `${opts?.baseUrl ?? getOauthConfig().BASE_API_URL}/v1/sessions/${compatId}`
logForDebugging(`[bridge] Updating session title: ${compatId}${title}`)
try {
const response = await axios.patch(
url,
{ title },
{ headers, timeout: 10_000, validateStatus: s => s < 500 },
)
if (response.status === 200) {
logForDebugging(`[bridge] Session title updated successfully`)
} else {
const detail = extractErrorDetail(response.data)
logForDebugging(
`[bridge] Session title update failed with status ${response.status}${detail ? `: ${detail}` : ''}`,
)
}
} catch (err: unknown) {
logForDebugging(
`[bridge] Session title update request failed: ${errorMessage(err)}`,
)
}
}

View File

@@ -0,0 +1,161 @@
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { jsonStringify } from '../utils/slowOperations.js'
const DEBUG_MSG_LIMIT = 2000
const SECRET_FIELD_NAMES = [
'session_ingress_token',
'environment_secret',
'access_token',
'secret',
'token',
]
const SECRET_PATTERN = new RegExp(
`"(${SECRET_FIELD_NAMES.join('|')})"\\s*:\\s*"([^"]*)"`,
'g',
)
const REDACT_MIN_LENGTH = 16
export function redactSecrets(s: string): string {
return s.replace(SECRET_PATTERN, (_match, field: string, value: string) => {
if (value.length < REDACT_MIN_LENGTH) {
return `"${field}":"[REDACTED]"`
}
const redacted = `${value.slice(0, 8)}...${value.slice(-4)}`
return `"${field}":"${redacted}"`
})
}
/** Truncate a string for debug logging, collapsing newlines. */
/** 为调试日志截断字符串,折叠换行符。 */
export function debugTruncate(s: string): string {
const flat = s.replace(/\n/g, '\\n')
if (flat.length <= DEBUG_MSG_LIMIT) {
return flat
}
return flat.slice(0, DEBUG_MSG_LIMIT) + `... (${flat.length} chars)`
}
/** Truncate a JSON-serializable value for debug logging. */
/** 为调试日志截断 JSON 可序列化值。 */
export function debugBody(data: unknown): string {
const raw = typeof data === 'string' ? data : jsonStringify(data)
const s = redactSecrets(raw)
if (s.length <= DEBUG_MSG_LIMIT) {
return s
}
return s.slice(0, DEBUG_MSG_LIMIT) + `... (${s.length} chars)`
}
/**
* Extract a descriptive error message from an axios error (or any error).
* For HTTP errors, appends the server's response body message if available,
* since axios's default message only includes the status code.
*/
/**
* 从 axios 错误(或任何错误)中提取描述性错误消息。
* 对于 HTTP 错误,如果可用,附加服务器的响应体消息,
* 因为 axios 的默认消息只包含状态码。
*/
export function describeAxiosError(err: unknown): string {
const msg = errorMessage(err)
if (err && typeof err === 'object' && 'response' in err) {
const response = (err as { response?: { data?: unknown } }).response
if (response?.data && typeof response.data === 'object') {
const data = response.data as Record<string, unknown>
const detail =
typeof data.message === 'string'
? data.message
: typeof data.error === 'object' &&
data.error &&
'message' in data.error &&
typeof (data.error as Record<string, unknown>).message ===
'string'
? (data.error as Record<string, unknown>).message
: undefined
if (detail) {
return `${msg}: ${detail}`
}
}
}
return msg
}
/**
* Extract the HTTP status code from an axios error, if present.
* Returns undefined for non-HTTP errors (e.g. network failures).
*/
/**
* 从 axios 错误中提取 HTTP 状态码(如果存在)。
* 对于非 HTTP 错误(例如网络故障)返回 undefined。
*/
export function extractHttpStatus(err: unknown): number | undefined {
if (
err &&
typeof err === 'object' &&
'response' in err &&
(err as { response?: { status?: unknown } }).response &&
typeof (err as { response: { status?: unknown } }).response.status ===
'number'
) {
return (err as { response: { status: number } }).response.status
}
return undefined
}
/**
* Pull a human-readable message out of an API error response body.
* Checks `data.message` first, then `data.error.message`.
*/
/**
* 从 API 错误响应体中提取人类可读的消息。
* 首先检查 `data.message`,然后是 `data.error.message`。
*/
export function extractErrorDetail(data: unknown): string | undefined {
if (!data || typeof data !== 'object') return undefined
if ('message' in data && typeof data.message === 'string') {
return data.message
}
if (
'error' in data &&
data.error !== null &&
typeof data.error === 'object' &&
'message' in data.error &&
typeof data.error.message === 'string'
) {
return data.error.message
}
return undefined
}
/**
* Log a bridge init skip — debug message + `tengu_bridge_repl_skipped`
* analytics event. Centralizes the event name and the AnalyticsMetadata
* cast so call sites don't each repeat the 5-line boilerplate.
*/
/**
* 记录 bridge init 跳过 — 调试消息 + `tengu_bridge_repl_skipped`
* 分析事件。集中事件名称和 AnalyticsMetadata 转换,
* 以便调用站点不必各自重复 5 行样板代码。
*/
export function logBridgeSkip(
reason: string,
debugMsg?: string,
v2?: boolean,
): void {
if (debugMsg) {
logForDebugging(debugMsg)
}
logEvent('tengu_bridge_repl_skipped', {
reason:
reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...(v2 !== undefined && { v2 }),
})
}

View File

@@ -0,0 +1,164 @@
import { z } from 'zod/v4'
import { getFeatureValue_DEPRECATED } from '../services/analytics/growthbook.js'
import { lazySchema } from '../utils/lazySchema.js'
import { lt } from '../utils/semver.js'
import { isEnvLessBridgeEnabled } from './bridgeEnabled.js'
export type EnvLessBridgeConfig = {
// withRetry — init 阶段退避createSession、POST /bridge、recovery /bridge
init_retry_max_attempts: number
init_retry_base_delay_ms: number
init_retry_jitter_fraction: number
init_retry_max_delay_ms: number
// POST /sessions、POST /bridge、POST /archive 的 axios 超时
http_timeout_ms: number
// BoundedUUIDSet 环形缓冲区大小echo + 重新投递去重)
uuid_dedup_buffer_size: number
// CCRClient worker 心跳节拍。服务器 TTL 为 60s — 20s 给予 3× 余量。
heartbeat_interval_ms: number
// ±间隔的分数 — 每个节拍抖动以分散集群负载。
heartbeat_jitter_fraction: number
// 在 expires_in 之前这么久触发主动 JWT 刷新。更大的缓冲区 =
// 更频繁的刷新(刷新节拍 ≈ expires_in - buffer
token_refresh_buffer_ms: number
// teardown() 中 Archive POST 超时。不同于 http_timeout_ms因为
// gracefulShutdown races runCleanupFunctions() 对抗 2s 上限 —
// 慢速/停滞 archive 上 10s axios 超时会在 forceExit 终止
// 请求之前消耗整个预算。
teardown_archive_timeout_ms: number
// transport.connect() 后 onConnect 的截止时间。如果既没有 onConnect
// 也没有 onClose 在此之前触发,发出 tengu_bridge_repl_connect_timeout —
// 这是 ~1% 会话的唯一遥测,这些会话发出 `started` 然后
// 沉默(无错误、无事件、只是什么都没有)。
connect_timeout_ms: number
// env-less bridge 路径的 Semver 下限。与 v1 的
// tengu_bridge_min_version 配置分开,以便 v2 特定 bug 可以强制升级
// 而不阻止 v1基于 env 的)客户端,反之亦然。
min_version: string
// 为 true 时,告诉用户他们的 claude.ai 应用可能太旧而无法看到 v2
// 会话 — 允许我们在应用发布新会话列表查询之前推出 v2
// bridge。
should_show_app_upgrade_message: boolean
}
export const DEFAULT_ENV_LESS_BRIDGE_CONFIG: EnvLessBridgeConfig = {
init_retry_max_attempts: 3,
init_retry_base_delay_ms: 500,
init_retry_jitter_fraction: 0.25,
init_retry_max_delay_ms: 4000,
http_timeout_ms: 10_000,
uuid_dedup_buffer_size: 2000,
heartbeat_interval_ms: 20_000,
heartbeat_jitter_fraction: 0.1,
token_refresh_buffer_ms: 300_000,
teardown_archive_timeout_ms: 1500,
connect_timeout_ms: 15_000,
min_version: '0.0.0',
should_show_app_upgrade_message: false,
}
// 下限在违规时拒绝整个对象(回退到 DEFAULT而不是
// 部分信任 — 与 pollConfig.ts 相同的纵深防御。
const envLessBridgeConfigSchema = lazySchema(() =>
z.object({
init_retry_max_attempts: z.number().int().min(1).max(10).default(3),
init_retry_base_delay_ms: z.number().int().min(100).default(500),
init_retry_jitter_fraction: z.number().min(0).max(1).default(0.25),
init_retry_max_delay_ms: z.number().int().min(500).default(4000),
http_timeout_ms: z.number().int().min(2000).default(10_000),
uuid_dedup_buffer_size: z.number().int().min(100).max(50_000).default(2000),
// 服务器 TTL 为 60s。下限 5s 防止抖动;上限 30s 保持 ≥2× 余量。
heartbeat_interval_ms: z
.number()
.int()
.min(5000)
.max(30_000)
.default(20_000),
// ±每节拍分数。上限 0.5在最大间隔30s× 1.5 = 最坏情况 45s
// 仍在 60s TTL 之内。
heartbeat_jitter_fraction: z.number().min(0).max(0.5).default(0.1),
// 下限 30s 防止 tight-looping。上限 30min 拒绝 buffer-vs-delay
// 语义反转:操作进入 expires_in-5min*延迟直到刷新*
// 而不是 5min*过期前的缓冲区*)会产生
// delayMs = expires_in - buffer ≈ 5min 而不是 ≈4h。两者都是正
// 时长,所以 .min() 单独无法区分;.max() 捕获
// 反转值,因为 buffer ≥ 30min 对于多小时 JWT 是无意义的。
token_refresh_buffer_ms: z
.number()
.int()
.min(30_000)
.max(1_800_000)
.default(300_000),
// 上限 2000 保持低于 gracefulShutdown 的 2s 清理竞争 —
// 更高的超时只是对 axios 说谎,因为 forceExit 无论如何都会终止套接字。
teardown_archive_timeout_ms: z
.number()
.int()
.min(500)
.max(2000)
.default(1500),
// 观察到的 p99 连接约 2-3s15s 约 5× 余量。下限 5s 限制
// 瞬态慢速下的误报率;上限 60s 限制真正停滞的会话保持黑暗的时长。
connect_timeout_ms: z.number().int().min(5_000).max(60_000).default(15_000),
min_version: z
.string()
.refine(v => {
try {
lt(v, '0.0.0')
return true
} catch {
return false
}
})
.default('0.0.0'),
should_show_app_upgrade_message: z.boolean().default(false),
}),
)
/**
* 从 GrowthBook 获取 env-less bridge 时序配置。每个
* initEnvLessBridgeCore 调用读取一次 — 配置在 bridge
* 会话的生命周期内是固定的。
*
* 使用阻塞 getter不是 _CACHED_MAY_BE_STALE因为 /remote-control
* 在 GrowthBook 初始化之后运行 — initializeGrowthBook() 立即解决,
* 所以没有启动惩罚,我们获得新鲜的 in-memory remoteEval
* 值而不是过时的首次读取磁盘缓存。_DEPRECATED 后缀
* 警告不要在启动路径使用,这不是。
*/
export async function getEnvLessBridgeConfig(): Promise<EnvLessBridgeConfig> {
const raw = await getFeatureValue_DEPRECATED<unknown>(
'tengu_bridge_repl_v2_config',
DEFAULT_ENV_LESS_BRIDGE_CONFIG,
)
const parsed = envLessBridgeConfigSchema().safeParse(raw)
return parsed.success ? parsed.data : DEFAULT_ENV_LESS_BRIDGE_CONFIG
}
/**
* 如果当前 CLI 版本低于 env-lessv2bridge 路径所需的最低
* 版本,返回错误消息;如果版本没问题则返回 null。
*
* checkBridgeMinVersion() 的 v2 类比 — 从 tengu_bridge_repl_v2_config
* 读取而不是 tengu_bridge_min_version以便两个实现可以强制
* 独立的下限。
*/
export async function checkEnvLessBridgeMinVersion(): Promise<string | null> {
const cfg = await getEnvLessBridgeConfig()
if (cfg.min_version && lt(MACRO.VERSION, cfg.min_version)) {
return `Your version of Claude Code (${MACRO.VERSION}) is too old for Remote Control.\nVersion ${cfg.min_version} or higher is required. Run \`claude update\` to update.`
}
return null
}
/**
* 当 Remote Control 会话启动时,是否提示用户升级他们的 claude.ai 应用。
* 仅当 v2 bridge 处于活动状态 AND should_show_app_upgrade_message 配置位
* 被设置时为 true — 允许我们在应用发布新会话列表查询之前
* 推出 v2 bridge。
*/
export async function shouldShowAppUpgradeMessage(): Promise<boolean> {
if (!isEnvLessBridgeEnabled()) return false
const cfg = await getEnvLessBridgeConfig()
return cfg.should_show_app_upgrade_message
}

View File

@@ -0,0 +1,93 @@
/**
* State machine for gating message writes during an initial flush.
*
* When a bridge session starts, historical messages are flushed to the
* server via a single HTTP POST. During that flush, new messages must
* be queued to prevent them from arriving at the server interleaved
* with the historical messages.
*
* Lifecycle:
* start() → enqueue() returns true, items are queued
* end() → returns queued items for draining, enqueue() returns false
* drop() → discards queued items (permanent transport close)
* deactivate() → clears active flag without dropping items
* (transport replacement — new transport will drain)
* 在初始 flush 期间 gating 消息写入的状态机。
*
* 当 bridge 会话启动时,历史消息通过单个 HTTP POST flush 到服务器。
* 在该 flush 期间,新消息必须排队以防止它们与历史消息
* 交错到达服务器。
*
* 生命周期:
* start() → enqueue() 返回 trueitems 被排队
* end() → 返回排队的 items 以便排空enqueue() 返回 false
* drop() → 丢弃排队的 items永久传输关闭
* deactivate() → 清除活动标志而不丢弃 items
* (传输替换 — 新传输将排空)
*/
export class FlushGate<T> {
private _active = false
private _pending: T[] = []
get active(): boolean {
return this._active
}
get pendingCount(): number {
return this._pending.length
}
/** Mark flush as in-progress. enqueue() will start queuing items. */
/** 标记 flush 进行中。enqueue() 将开始排队 items。 */
start(): void {
this._active = true
}
/**
* End the flush and return any queued items for draining.
* Caller is responsible for sending the returned items.
* 结束 flush 并返回任何排队的 items 以便排空。
* 调用者负责发送返回的 items。
*/
end(): T[] {
this._active = false
return this._pending.splice(0)
}
/**
* If flush is active, queue the items and return true.
* If flush is not active, return false (caller should send directly).
* 如果 flush 是活动的,排队 items 并返回 true。
* 如果 flush 不是活动的,返回 false调用者应直接发送
*/
enqueue(...items: T[]): boolean {
if (!this._active) return false
this._pending.push(...items)
return true
}
/**
* Discard all queued items (permanent transport close).
* Returns the number of items dropped.
* 丢弃所有排队的 items永久传输关闭
* 返回丢弃的 items 数量。
*/
drop(): number {
this._active = false
const count = this._pending.length
this._pending.length = 0
return count
}
/**
* Clear the active flag without dropping queued items.
* Used when the transport is replaced (onWorkReceived) — the new
* transport's flush will drain the pending items.
* 清除活动标志而不丢弃排队的 items。
* 在传输被替换时使用onWorkReceived— 新
* 传输的 flush 将排空待处理的 items。
*/
deactivate(): void {
this._active = false
}
}

View File

@@ -0,0 +1,566 @@
/**
* REPL 专用的 initBridgeCore 包装器。拥有从 bootstrap 状态读取的部分
* — gates、cwd、session ID、git context、OAuth、title 派生
* — 然后委托给无 bootstrap 的核心。
*
* 从 replBridge.ts 中拆分出来,因为 sessionStorage 导入
*getCurrentSessionTitle传递引入 src/commands.ts → 整个
* 斜杠命令 + React 组件树(~1300 个模块)。将
* initBridgeCore 保持在不触及 sessionStorage 的文件中允许
* daemonBridge.ts 导入核心而不膨胀 Agent SDK bundle。
*
* 通过动态导入调用,由 useReplBridge自动启动和 print.ts
*SDK -p 模式通过 query.enableRemoteControl使用。
*/
import { feature } from 'bun:bundle'
import { hostname } from 'os'
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
import type { SDKMessage } from '../entrypoints/agentSdkTypes.js'
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'
import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js'
import { getOrganizationUUID } from '../services/oauth/client.js'
import {
isPolicyAllowed,
waitForPolicyLimitsToLoad,
} from '../services/policyLimits/index.js'
import type { Message } from '../types/message.js'
import {
checkAndRefreshOAuthTokenIfNeeded,
getClaudeAIOAuthTokens,
handleOAuth401Error,
} from '../utils/auth.js'
import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
import { logForDebugging } from '../utils/debug.js'
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
import { errorMessage } from '../utils/errors.js'
import { getBranch, getRemoteUrl } from '../utils/git.js'
import { toSDKMessages } from '../utils/messages/mappers.js'
import {
getContentText,
getMessagesAfterCompactBoundary,
isSyntheticMessage,
} from '../utils/messages.js'
import type { PermissionMode } from '../utils/permissions/PermissionMode.js'
import { getCurrentSessionTitle } from '../utils/sessionStorage.js'
import {
extractConversationText,
generateSessionTitle,
} from '../utils/sessionTitle.js'
import { generateShortWordSlug } from '../utils/words.js'
import {
getBridgeAccessToken,
getBridgeBaseUrl,
getBridgeTokenOverride,
} from './bridgeConfig.js'
import {
checkBridgeMinVersion,
isBridgeEnabledBlocking,
isCseShimEnabled,
isEnvLessBridgeEnabled,
} from './bridgeEnabled.js'
import {
archiveBridgeSession,
createBridgeSession,
updateBridgeSessionTitle,
} from './createSession.js'
import { logBridgeSkip } from './debugUtils.js'
import { checkEnvLessBridgeMinVersion } from './envLessBridgeConfig.js'
import { getPollIntervalConfig } from './pollConfig.js'
import type { BridgeState, ReplBridgeHandle } from './replBridge.js'
import { initBridgeCore } from './replBridge.js'
import { setCseShimGate } from './sessionIdCompat.js'
import type { BridgeWorkerType } from './types.js'
export type InitBridgeOptions = {
onInboundMessage?: (msg: SDKMessage) => void | Promise<void>
onPermissionResponse?: (response: SDKControlResponse) => void
onInterrupt?: () => void
onSetModel?: (model: string | undefined) => void
onSetMaxThinkingTokens?: (maxTokens: number | null) => void
onSetPermissionMode?: (
mode: PermissionMode,
) => { ok: true } | { ok: false; error: string }
onStateChange?: (state: BridgeState, detail?: string) => void
initialMessages?: Message[]
// 来自 `/remote-control <name>` 的显式会话名称。设置时,覆盖
// 从对话或 /rename 派生的标题。
initialName?: string
// 调用时对完整对话的最新视图。用于 onUserMessage 的
// count-3 派生以对完整对话调用 generateSessionTitle。
// 可选 — print.ts 的 SDK enableRemoteControl 路径没有 REPL 消息
// 数组count-3 在缺失时回退到单条消息文本。
getMessages?: () => Message[]
// 在之前的 bridge 会话中已 flush 的 UUID。带有这些
// UUID 的消息从初始 flush 中排除,以避免污染
// 服务器(跨会话的重复 UUID 导致 WS 被杀死)。
// 原地修改 — 每次 flush 后添加新 flush 的 UUID。
previouslyFlushedUUIDs?: Set<string>
/** 参见 BridgeCoreParams.perpetual。 */
perpetual?: boolean
/**
* 为 true 时bridge 只转发事件出站(无 SSE 入站
* 流)。用于 CCR 镜像模式 — 本地会话在 claude.ai 上可见
* 而不启用入站控制。
*/
outboundOnly?: boolean
tags?: string[]
}
export async function initReplBridge(
options?: InitBridgeOptions,
): Promise<ReplBridgeHandle | null> {
const {
onInboundMessage,
onPermissionResponse,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
onStateChange,
initialMessages,
getMessages,
previouslyFlushedUUIDs,
initialName,
perpetual,
outboundOnly,
tags,
} = options ?? {}
// 连接 cse_ shim kill switch 以便 toCompatSessionId 遵循
// GrowthBook gate。Daemon/SDK 路径跳过此操作 — shim 默认为活动状态。
setCseShimGate(isCseShimEnabled)
// 1. 运行时 gate
if (!(await isBridgeEnabledBlocking())) {
logBridgeSkip('not_enabled', '[bridge:repl] Skipping: bridge not enabled')
return null
}
// 1b. 最低版本检查 — 推迟到下面的 v1/v2 分支之后,
// 因为每个实现有自己的下限v1 的 tengu_bridge_min_version
// v2 的 tengu_bridge_repl_v2_config.min_version
// 2. 检查 OAuth — 必须使用 claude.ai 登录。在
// 策略检查之前运行,以便控制台-auth 用户获得可操作的 "/login" 提示
// 而不是来自过时/错误组织缓存的误导性策略错误。
if (!getBridgeAccessToken()) {
logBridgeSkip('no_oauth', '[bridge:repl] Skipping: no OAuth tokens')
onStateChange?.('failed', '/login')
return null
}
// 3. 检查组织策略 — 远程控制可能已被禁用
await waitForPolicyLimitsToLoad()
if (!isPolicyAllowed('allow_remote_control')) {
logBridgeSkip(
'policy_denied',
'[bridge:repl] Skipping: allow_remote_control policy not allowed',
)
onStateChange?.('failed', "disabled by your organization's policy")
return null
}
// 当设置了 CLAUDE_BRIDGE_OAUTH_TOKEN 时ant 专用本地开发bridge
// 通过 getBridgeAccessToken() 直接使用该 token — keychain 状态
// 无关。跳过 2b/2c 以保持这种解耦:过期的 keychain
// token 不应阻止不使用它的 bridge 连接。
if (!getBridgeTokenOverride()) {
// 2a. 跨进程退避。如果 N 个先前进程已经看到相同的
// 过期 token通过 expiresAt 匹配),静默跳过 — 无事件、无刷新
// 尝试。计数阈值容忍临时刷新失败auth
// 服务器 5xx、lockfile 错误 per auth.ts:1437/1444/1485每个进程
// 独立重试直到 3 次连续失败证明 token 已死。
// 使用 useReplBridge 的 MAX_CONSECUTIVE_INIT_FAILURES 进行进程内镜像。
// expiresAt 密钥是内容寻址的:/login → 新 token → 新 expiresAt
// → 这在不进行任何显式清除的情况下停止匹配。
const cfg = getGlobalConfig()
if (
cfg.bridgeOauthDeadExpiresAt != null &&
(cfg.bridgeOauthDeadFailCount ?? 0) >= 3 &&
getClaudeAIOAuthTokens()?.expiresAt === cfg.bridgeOauthDeadExpiresAt
) {
logForDebugging(
`[bridge:repl] Skipping: cross-process backoff (dead token seen ${cfg.bridgeOauthDeadFailCount} times)`,
)
return null
}
// 2b. 主动刷新如果已过期。镜像 bridgeMain.ts:2096 — REPL
// bridge 在任何 v1/messages 调用之前在 useEffect mount 触发,使这
// 通常是会话的第一次 OAuth 请求。没有这个,约 9% 的
// 注册使用 >8h 过期的 token 命中服务器 → 401 → withOAuthRetry
// 恢复,但服务器记录了我们能够避免的 401。观察到的 VPN 出口 IP
// 在 8h TTL 边界附近有 30:1 的 401:200 比率。
//
// 新鲜 token 成本:一次 memoized 读取 + 一次 Date.now() 比较(~µs
// checkAndRefreshOAuthTokenIfNeeded 在每个触及 keychain 的路径中清除其自己的缓存
//刷新成功、lockfile 竞态、抛出),所以这里没有
// 显式 clearOAuthTokenCache() — 这将在 91%+ 新鲜 token 路径上
// 强制进行阻塞 keychain 生成。
await checkAndRefreshOAuthTokenIfNeeded()
// 2c. 在刷新尝试后如果 token 仍然过期则跳过。Env-var / FD
// tokenauth.ts:894-917有 expiresAt=null → 从不触发这个。但
// 刷新 token 已死的 keychain token密码更改、组织离开、
// token 被 GC'd有 expiresAt<now AND 刷新刚刚失败 — 客户端将
// 否则永远循环 401withOAuthRetry → handleOAuth401Error →
// 刷新再次失败 → 使用相同的过时 token 重试 → 再次 401。
// Datadog 2026-03-08单个 IP 每天产生 2,879 个这样的 401。跳过
// 保证失败的 API 调用useReplBridge 显示失败。
//
// 故意不使用 isOAuthTokenExpired — 那有 5 分钟的主动刷新缓冲区,
// 这对 "应该很快刷新" 是正确的启发法,但对 "证明不可用" 是错误的。
// 有 3 分钟剩余的 token + 临时刷新端点抖动5xx/timeout/wifi-reconnect
// 会错误地触发缓冲检查;仍然有效的 token 可以正常连接。
// 检查实际过期而不是:过期 AND 刷新失败 → 真正死亡。
const tokens = getClaudeAIOAuthTokens()
if (tokens && tokens.expiresAt !== null && tokens.expiresAt <= Date.now()) {
logBridgeSkip(
'oauth_expired_unrefreshable',
'[bridge:repl] Skipping: OAuth token expired and refresh failed (re-login required)',
)
onStateChange?.('failed', '/login')
// 为下一个进程持久化。在重新发现相同的死亡 token 时增加 failCount
//(通过 expiresAt 匹配);对于不同的 token 重置为 1。
// 一旦计数达到 3步骤 2a 的早期返回触发,此路径
// 永远不会再到达 — 每个死亡 token 的写入被限制为 3 次。
// 局部 const 捕获缩小的类型(闭包失去 !==null 缩小)。
const deadExpiresAt = tokens.expiresAt
saveGlobalConfig(c => ({
...c,
bridgeOauthDeadExpiresAt: deadExpiresAt,
bridgeOauthDeadFailCount:
c.bridgeOauthDeadExpiresAt === deadExpiresAt
? (c.bridgeOauthDeadFailCount ?? 0) + 1
: 1,
}))
return null
}
}
// 4. 计算 baseUrl — v1基于 env和 v2无 env
// 路径都需要。提升到 v2 gate 之上,以便两者都可以使用它。
const baseUrl = getBridgeBaseUrl()
// 5. 派生会话标题。优先级:显式 initialName → /rename
//(会话存储)→ 最后有意义的用户消息 → 生成的 slug。
// 仅用于装饰claude.ai 会话列表);模型从不看到它。
// 两个标志:`hasExplicitTitle`initialName 或 /rename — 从不自动
// 覆盖vs. `hasTitle`(任何标题,包括自动派生 — 阻止
// count-1 重新派生但不阻止 count-3。onUserMessage 回调
//(连接到下面的 v1 和 v2从第一个提示派生
// 然后从第三个再次派生,以便 mobile/web 显示反映更多上下文的标题。
// slug 后备(例如 "remote-control-graceful-unicorn")使
// 自动启动的会话在第一个提示之前的 claude.ai 列表中可区分。
let title = `remote-control-${generateShortWordSlug()}`
let hasTitle = false
let hasExplicitTitle = false
if (initialName) {
title = initialName
hasTitle = true
hasExplicitTitle = true
} else {
const sessionId = getSessionId()
const customTitle = sessionId
? getCurrentSessionTitle(sessionId)
: undefined
if (customTitle) {
title = customTitle
hasTitle = true
hasExplicitTitle = true
} else if (initialMessages && initialMessages.length > 0) {
// 查找最后一条有意义的用户消息。跳过 meta
//nudges、工具结果、压缩摘要"This session is being
// continued…")、非人类来源(任务通知、频道推送)、
// 和合成中断([Request interrupted by user])— 都不是
// 人类编写的。与 extractTitleText + isSyntheticMessage 相同的过滤器。
for (let i = initialMessages.length - 1; i >= 0; i--) {
const msg = initialMessages[i]!
if (
msg.type !== 'user' ||
msg.isMeta ||
msg.toolUseResult ||
msg.isCompactSummary ||
(msg.origin && msg.origin.kind !== 'human') ||
isSyntheticMessage(msg)
)
continue
const rawContent = getContentText(msg.message.content)
if (!rawContent) continue
const derived = deriveTitle(rawContent)
if (!derived) continue
title = derived
hasTitle = true
break
}
}
}
// v1 和 v2 共享 — 在每个有标题价值的用户消息上触发直到
// 返回 true。在 count 1立即派生标题占位符然后
// generateSessionTitleHaiku句首大写 fire-and-forget 升级。在
// count 3在完整对话上重新生成。如果
// 标题是显式的(/remote-control <name> 或 /rename则完全跳过
// — 在调用时重新检查 sessionStorage以便 /rename 在消息之间不会混淆。
// 如果 initialMessages 已派生则跳过 count 1该标题是新鲜的
// 仍在 count 3 刷新。v2 传递 cse_*updateBridgeSessionTitle
// 在内部重新标记。
let userMessageCount = 0
let lastBridgeSessionId: string | undefined
let genSeq = 0
const patch = (
derived: string,
bridgeSessionId: string,
atCount: number,
): void => {
hasTitle = true
title = derived
logForDebugging(
`[bridge:repl] derived title from message ${atCount}: ${derived}`,
)
void updateBridgeSessionTitle(bridgeSessionId, derived, {
baseUrl,
getAccessToken: getBridgeAccessToken,
}).catch(() => {})
}
// Fire-and-forget Haiku 生成,带 post-await guards。重新检查 /rename
//sessionStorage、v1 env-lostlastBridgeSessionId和同会话
// 乱序解析genSeq — count-1 的 Haiku 在 count-3 之后解析
// 会混淆更丰富的标题。generateSessionTitle 从不拒绝。
const generateAndPatch = (input: string, bridgeSessionId: string): void => {
const gen = ++genSeq
const atCount = userMessageCount
void generateSessionTitle(input, AbortSignal.timeout(15_000)).then(
generated => {
if (
generated &&
gen === genSeq &&
lastBridgeSessionId === bridgeSessionId &&
!getCurrentSessionTitle(getSessionId())
) {
patch(generated, bridgeSessionId, atCount)
}
},
)
}
const onUserMessage = (text: string, bridgeSessionId: string): boolean => {
if (hasExplicitTitle || getCurrentSessionTitle(getSessionId())) {
return true
}
// v1 env-lost 使用新 ID 重新创建会话。重置计数以便
// 新会话获得自己的 count-3 派生hasTitle 保持为 true
//(新会话是通过 getCurrentTitle() 创建的,它从该闭包读取 count-1
// 标题),因此新鲜周期的 count-1 正确跳过。
if (
lastBridgeSessionId !== undefined &&
lastBridgeSessionId !== bridgeSessionId
) {
userMessageCount = 0
}
lastBridgeSessionId = bridgeSessionId
userMessageCount++
if (userMessageCount === 1 && !hasTitle) {
const placeholder = deriveTitle(text)
if (placeholder) patch(placeholder, bridgeSessionId, userMessageCount)
generateAndPatch(text, bridgeSessionId)
} else if (userMessageCount === 3) {
const msgs = getMessages?.()
const input = msgs
? extractConversationText(getMessagesAfterCompactBoundary(msgs))
: text
generateAndPatch(input, bridgeSessionId)
}
// 同样在 v1 env-lost 重置传输的 done 标志超过 3 时重新闩锁。
return userMessageCount >= 3
}
const initialHistoryCap = getFeatureValue_CACHED_WITH_REFRESH(
'tengu_bridge_initial_history_cap',
200,
5 * 60 * 1000,
)
// 在 v1/v2 分支之前获取 orgUUID — 两条路径都需要它。v1 用于
// 环境注册v2 用于归档(位于兼容的
// /v1/sessions/{id}/archive而不是 /v1/code/sessions。没有它v2
// 归档 404 并且会话在 /exit 后在 CCR 中保持活动。
const orgUUID = await getOrganizationUUID()
if (!orgUUID) {
logBridgeSkip('no_org_uuid', '[bridge:repl] Skipping: no org UUID')
onStateChange?.('failed', '/login')
return null
}
// ── GrowthBook gate: 无 env bridge ──────────────────────────────────
// 启用时,完全跳过 Environments API 层(无 register/
// poll/ack/heartbeat并通过 POST /bridge → worker_jwt 直接连接。
// 参见 server PR #292605#293280 中重命名。REPL 专用 — daemon/print 留在
// 基于 env 的。
//
// 命名:"env-less" 不同于 "CCR v2"/worker/* 传输)。
// 下面的基于 env 的路径也可以通过 CLAUDE_CODE_USE_CCR_V2 使用 CCR v2。
// tengu_bridge_repl_v2 控制 env-less无 poll 循环),而不是传输版本。
//
// perpetual通过 bridge-pointer.json 的 assistant 模式会话连续性)是
// env 耦合的,尚未在此实现 — 回退到基于 env 的当设置
// 以便 KAIROS 用户不会静默丢失跨重启连续性。
if (isEnvLessBridgeEnabled() && !perpetual) {
const versionError = await checkEnvLessBridgeMinVersion()
if (versionError) {
logBridgeSkip(
'version_too_old',
`[bridge:repl] Skipping: ${versionError}`,
true,
)
onStateChange?.('failed', 'run `claude update` to upgrade')
return null
}
logForDebugging(
'[bridge:repl] Using env-less bridge path (tengu_bridge_repl_v2)',
)
const { initEnvLessBridgeCore } = await import('./remoteBridgeCore.js')
return initEnvLessBridgeCore({
baseUrl,
orgUUID,
title,
getAccessToken: getBridgeAccessToken,
onAuth401: handleOAuth401Error,
toSDKMessages,
initialHistoryCap,
initialMessages,
// v2 总是创建新的服务器会话(新的 cse_* id所以
// previouslyFlushedUUIDs 未传递 — 没有跨会话
// UUID 碰撞风险,并且 ref 在 enable→disable→
// 重新启用周期中持续存在,这会导致新会话收到零
// 历史(所有 UUID 已存在于先前启用的集合中)。
// v1 通过在新鲜会话创建时调用 previouslyFlushedUUIDs.clear() 处理
//replBridge.ts:768v2 完全跳过该参数。
onInboundMessage,
onUserMessage,
onPermissionResponse,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
onStateChange,
outboundOnly,
tags,
})
}
// ── v1 路径:基于 envregister/poll/ack/heartbeat ──────────────────
const versionError = checkBridgeMinVersion()
if (versionError) {
logBridgeSkip('version_too_old', `[bridge:repl] Skipping: ${versionError}`)
onStateChange?.('failed', 'run `claude update` to upgrade')
return null
}
// 收集 git context — 这是 bootstrap-read 边界。
// 从这里开始的所有内容都显式传递给 bridgeCore。
const branch = await getBranch()
const gitRepoUrl = await getRemoteUrl()
const sessionIngressUrl =
process.env.USER_TYPE === 'ant' &&
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
: baseUrl
// Assistant 模式会话宣传不同的 worker_type以便 Web UI
// 可以将它们过滤到专用选择器中。KAIROS guard 保持
// assistant 模块完全在外部构建之外。
let workerType: BridgeWorkerType = 'claude_code'
if (feature('KAIROS')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { isAssistantMode } =
require('../assistant/index.js') as typeof import('../assistant/index.js')
/* eslint-enable @typescript-eslint/no-require-imports */
if (isAssistantMode()) {
workerType = 'claude_code_assistant'
}
}
// 6. 委托。BridgeCoreHandle 是 ReplBridgeHandle 的结构超集
//(添加了 REPL 调用者不使用的 writeSdkMessages
// 所以不需要适配器 — 只是输出时的较窄类型。
return initBridgeCore({
dir: getOriginalCwd(),
machineName: hostname(),
branch,
gitRepoUrl,
title,
baseUrl,
sessionIngressUrl,
workerType,
getAccessToken: getBridgeAccessToken,
createSession: opts =>
createBridgeSession({
...opts,
events: [],
baseUrl,
getAccessToken: getBridgeAccessToken,
}),
archiveSession: sessionId =>
archiveBridgeSession(sessionId, {
baseUrl,
getAccessToken: getBridgeAccessToken,
// gracefulShutdown.ts:407 将 runCleanupFunctions 与 2s 对抗。
// Teardown 还做 stopWork并行+ deregister顺序
// 所以 archive 不能有完整预算。1.5s 匹配 v2 的
// teardown_archive_timeout_ms 默认值。
timeoutMs: 1500,
}).catch((err: unknown) => {
// archiveBridgeSession 没有 try/catch — 5xx/timeout/network 抛出
// 直通。以前静默吞下,使归档失败在 BQ 中不可见且无法从调试日志诊断。
logForDebugging(
`[bridge:repl] archiveBridgeSession threw: ${errorMessage(err)}`,
{ level: 'error' },
)
}),
// getCurrentTitle 在重新连接后 env-lost 时重新读取以重新命名新
// 会话。/rename 写入会话存储onUserMessage 直接改变
// `title` — 两条路径都在这里被拾取。
getCurrentTitle: () => getCurrentSessionTitle(getSessionId()) ?? title,
onUserMessage,
toSDKMessages,
onAuth401: handleOAuth401Error,
getPollIntervalConfig,
initialHistoryCap,
initialMessages,
previouslyFlushedUUIDs,
onInboundMessage,
onPermissionResponse,
onInterrupt,
onSetModel,
onSetMaxThinkingTokens,
onSetPermissionMode,
onStateChange,
perpetual,
})
}
const TITLE_MAX_LEN = 50
/**
* 快速占位符标题:剥离显示标签,取第一个句子,
* 折叠空白,截断至 50 个字符。如果结果
* 为空(例如消息仅包含 <local-command-stdout>)则返回 undefined。
* 一旦 Haiku 解析(~1-15s就被 generateSessionTitle 替换。
*/
function deriveTitle(raw: string): string | undefined {
// 剥离 <ide_opened_file>、<session-start-hook> 等 — 这些在
// IDE/hooks 注入上下文时出现在用户消息中。stripDisplayTagsAllowEmpty
// 返回 ''(不是原始的),所以纯标签消息被跳过。
const clean = stripDisplayTagsAllowEmpty(raw)
// 第一个句子通常是意图;其余通常是上下文/细节。
// 使用捕获组而不是后行断言 — 保持 YARR JIT 愉快。
const firstSentence = /^(.*?[.!?])\s/.exec(clean)?.[1] ?? clean
// 折叠换行符/制表符 — claude.ai 列表中的标题是单行的。
const flat = firstSentence.replace(/\s+/g, ' ').trim()
if (!flat) return undefined
return flat.length > TITLE_MAX_LEN
? flat.slice(0, TITLE_MAX_LEN - 1) + '\u2026'
: flat
}

View File

@@ -0,0 +1,306 @@
import { logEvent } from '../services/analytics/index.js'
import { logForDebugging } from '../utils/debug.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { errorMessage } from '../utils/errors.js'
import { jsonParse } from '../utils/slowOperations.js'
/** Format a millisecond duration as a human-readable string (e.g. "5m 30s"). */
/** 将毫秒持续时间格式化为人类可读的字符串(例如 "5m 30s")。 */
function formatDuration(ms: number): string {
if (ms < 60_000) return `${Math.round(ms / 1000)}s`
const m = Math.floor(ms / 60_000)
const s = Math.round((ms % 60_000) / 1000)
return s > 0 ? `${m}m ${s}s` : `${m}m`
}
/**
* Decode a JWT's payload segment without verifying the signature.
* Strips the `sk-ant-si-` session-ingress prefix if present.
* Returns the parsed JSON payload as `unknown`, or `null` if the
* token is malformed or the payload is not valid JSON.
*/
/**
* 在不验证签名的情况下解码 JWT 的 payload 段。
* 如果存在,剥离 `sk-ant-si-` session-ingress 前缀。
* 返回解析的 JSON payload 为 `unknown`,如果
* token 格式错误或 payload 不是有效 JSON 则返回 `null`。
*/
export function decodeJwtPayload(token: string): unknown | null {
const jwt = token.startsWith('sk-ant-si-')
? token.slice('sk-ant-si-'.length)
: token
const parts = jwt.split('.')
if (parts.length !== 3 || !parts[1]) return null
try {
return jsonParse(Buffer.from(parts[1], 'base64url').toString('utf8'))
} catch {
return null
}
}
/**
* Decode the `exp` (expiry) claim from a JWT without verifying the signature.
* @returns The `exp` value in Unix seconds, or `null` if unparseable
*/
/**
* 在不验证签名的情况下从 JWT 解码 `exp`过期claim。
* @returns Unix 秒中的 `exp` 值,如果无法解析则返回 `null`
*/
export function decodeJwtExpiry(token: string): number | null {
const payload = decodeJwtPayload(token)
if (
payload !== null &&
typeof payload === 'object' &&
'exp' in payload &&
typeof payload.exp === 'number'
) {
return payload.exp
}
return null
}
/** Refresh buffer: request a new token before expiry. */
/** 刷新缓冲区:在过期前请求新 token。 */
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000
/** Fallback refresh interval when the new token's expiry is unknown. */
/** 当新 token 的过期时间未知时的回退刷新间隔。 */
const FALLBACK_REFRESH_INTERVAL_MS = 30 * 60 * 1000 // 30 minutes
/** Max consecutive failures before giving up on the refresh chain. */
/** 在放弃刷新链之前最大连续失败次数。 */
const MAX_REFRESH_FAILURES = 3
/** Retry delay when getAccessToken returns undefined. */
/** 当 getAccessToken 返回 undefined 时的重试延迟。 */
const REFRESH_RETRY_DELAY_MS = 60_000
/**
* Creates a token refresh scheduler that proactively refreshes session tokens
* before they expire. Used by both the standalone bridge and the REPL bridge.
*
* When a token is about to expire, the scheduler calls `onRefresh` with the
* session ID and the bridge's OAuth access token. The caller is responsible
* for delivering the token to the appropriate transport (child process stdin
* for standalone bridge, WebSocket reconnect for REPL bridge).
*/
/**
* 创建一个 token 刷新调度器,在 session tokens 过期前主动刷新。
* 独立 bridge 和 REPL bridge 都使用。
*
* 当 token 即将过期时,调度器使用 session ID 和 bridge 的 OAuth access token
* 调用 `onRefresh`。调用者负责将 token 传递到适当的传输
*(独立 bridge 的子进程 stdinREPL bridge 的 WebSocket 重连)。
*/
export function createTokenRefreshScheduler({
getAccessToken,
onRefresh,
label,
refreshBufferMs = TOKEN_REFRESH_BUFFER_MS,
}: {
getAccessToken: () => string | undefined | Promise<string | undefined>
onRefresh: (sessionId: string, oauthToken: string) => void
label: string
/** How long before expiry to fire refresh. Defaults to 5 min. */
/** 过期前多久触发刷新。默认为 5 分钟。 */
refreshBufferMs?: number
}): {
schedule: (sessionId: string, token: string) => void
scheduleFromExpiresIn: (sessionId: string, expiresInSeconds: number) => void
cancel: (sessionId: string) => void
cancelAll: () => void
} {
const timers = new Map<string, ReturnType<typeof setTimeout>>()
const failureCounts = new Map<string, number>()
// Generation counter per session — incremented by schedule() and cancel()
// so that in-flight async doRefresh() calls can detect when they've been
// superseded and should skip setting follow-up timers.
// 每个会话的 generation 计数器 — 由 schedule() 和 cancel() 递增,
// 以便 in-flight async doRefresh() 调用可以检测何时被取代,
// 并应跳过设置后续计时器。
const generations = new Map<string, number>()
function nextGeneration(sessionId: string): number {
const gen = (generations.get(sessionId) ?? 0) + 1
generations.set(sessionId, gen)
return gen
}
function schedule(sessionId: string, token: string): void {
const expiry = decodeJwtExpiry(token)
if (!expiry) {
// Token is not a decodable JWT (e.g. an OAuth token passed from the
// REPL bridge WebSocket open handler). Preserve any existing timer
// (such as the follow-up refresh set by doRefresh) so the refresh
// chain is not broken.
// Token 不是可解码的 JWT例如从 REPL bridge WebSocket 打开处理程序
// 传递的 OAuth token。保留任何现有计时器
//(如 doRefresh 设置的后续刷新),以便刷新链不断裂。
logForDebugging(
`[${label}:token] Could not decode JWT expiry for sessionId=${sessionId}, token prefix=${token.slice(0, 15)}…, keeping existing timer`,
)
return
}
// Clear any existing refresh timer — we have a concrete expiry to replace it.
// 清除任何现有刷新计时器 — 我们有具体过期时间来替换它。
const existing = timers.get(sessionId)
if (existing) {
clearTimeout(existing)
}
// Bump generation to invalidate any in-flight async doRefresh.
// 增加 generation 以使任何 in-flight async doRefresh 无效。
const gen = nextGeneration(sessionId)
const expiryDate = new Date(expiry * 1000).toISOString()
const delayMs = expiry * 1000 - Date.now() - refreshBufferMs
if (delayMs <= 0) {
logForDebugging(
`[${label}:token] Token for sessionId=${sessionId} expires=${expiryDate} (past or within buffer), refreshing immediately`,
)
void doRefresh(sessionId, gen)
return
}
logForDebugging(
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires=${expiryDate}, buffer=${refreshBufferMs / 1000}s)`,
)
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
timers.set(sessionId, timer)
}
/**
* Schedule refresh using an explicit TTL (seconds until expiry) rather
* than decoding a JWT's exp claim. Used by callers whose JWT is opaque
* (e.g. POST /v1/code/sessions/{id}/bridge returns expires_in directly).
*/
/**
* 使用明确的 TTL到过期的秒数调度刷新
* 而不是解码 JWT 的 exp claim。用于 JWT 不透明的调用者
*(例如 POST /v1/code/sessions/{id}/bridge 直接返回 expires_in
*/
function scheduleFromExpiresIn(
sessionId: string,
expiresInSeconds: number,
): void {
const existing = timers.get(sessionId)
if (existing) clearTimeout(existing)
const gen = nextGeneration(sessionId)
// Clamp to 30s floor — if refreshBufferMs exceeds the server's expires_in
// (e.g. very large buffer for frequent-refresh testing, or server shortens
// expires_in unexpectedly), unclamped delayMs ≤ 0 would tight-loop.
// 钳制到 30s 下限 — 如果 refreshBufferMs 超过服务器的 expires_in
//(例如用于频繁刷新测试的非常大缓冲区,或服务器意外缩短
// expires_in未钳制的 delayMs ≤ 0 会 tight-loop。
const delayMs = Math.max(expiresInSeconds * 1000 - refreshBufferMs, 30_000)
logForDebugging(
`[${label}:token] Scheduled token refresh for sessionId=${sessionId} in ${formatDuration(delayMs)} (expires_in=${expiresInSeconds}s, buffer=${refreshBufferMs / 1000}s)`,
)
const timer = setTimeout(doRefresh, delayMs, sessionId, gen)
timers.set(sessionId, timer)
}
async function doRefresh(sessionId: string, gen: number): Promise<void> {
let oauthToken: string | undefined
try {
oauthToken = await getAccessToken()
} catch (err) {
logForDebugging(
`[${label}:token] getAccessToken threw for sessionId=${sessionId}: ${errorMessage(err)}`,
{ level: 'error' },
)
}
// If the session was cancelled or rescheduled while we were awaiting,
// the generation will have changed — bail out to avoid orphaned timers.
// 如果会话在我们等待时被取消或重新调度generation 将已更改 —
// 跳出以避免孤立的计时器。
if (generations.get(sessionId) !== gen) {
logForDebugging(
`[${label}:token] doRefresh for sessionId=${sessionId} stale (gen ${gen} vs ${generations.get(sessionId)}), skipping`,
)
return
}
if (!oauthToken) {
const failures = (failureCounts.get(sessionId) ?? 0) + 1
failureCounts.set(sessionId, failures)
logForDebugging(
`[${label}:token] No OAuth token available for refresh, sessionId=${sessionId} (failure ${failures}/${MAX_REFRESH_FAILURES})`,
{ level: 'error' },
)
logForDiagnosticsNoPII('error', 'bridge_token_refresh_no_oauth')
// Schedule a retry so the refresh chain can recover if the token
// becomes available again (e.g. transient cache clear during refresh).
// Cap retries to avoid spamming on genuine failures.
// 调度重试,以便如果 token 再次可用(例如刷新期间瞬态缓存清除),
// 刷新链可以恢复。限制重试次数以避免在真正失败时轰炸。
if (failures < MAX_REFRESH_FAILURES) {
const retryTimer = setTimeout(
doRefresh,
REFRESH_RETRY_DELAY_MS,
sessionId,
gen,
)
timers.set(sessionId, retryTimer)
}
return
}
// Reset failure counter on successful token retrieval
// 成功获取 token 时重置失败计数器
failureCounts.delete(sessionId)
logForDebugging(
`[${label}:token] Refreshing token for sessionId=${sessionId}: new token prefix=${oauthToken.slice(0, 15)}`,
)
logEvent('tengu_bridge_token_refreshed', {})
onRefresh(sessionId, oauthToken)
// Schedule a follow-up refresh so long-running sessions stay authenticated.
// Without this, the initial one-shot timer leaves the session vulnerable
// to token expiry if it runs past the first refresh window.
// 调度后续刷新,以便长运行会话保持认证。
// 没有这个,初始一次性计时器使会话容易在运行超过第一个刷新窗口时
// token 过期。
const timer = setTimeout(
doRefresh,
FALLBACK_REFRESH_INTERVAL_MS,
sessionId,
gen,
)
timers.set(sessionId, timer)
logForDebugging(
`[${label}:token] Scheduled follow-up refresh for sessionId=${sessionId} in ${formatDuration(FALLBACK_REFRESH_INTERVAL_MS)}`,
)
}
function cancel(sessionId: string): void {
// Bump generation to invalidate any in-flight async doRefresh.
// 增加 generation 以使任何 in-flight async doRefresh 无效。
nextGeneration(sessionId)
const timer = timers.get(sessionId)
if (timer) {
clearTimeout(timer)
timers.delete(sessionId)
}
failureCounts.delete(sessionId)
}
function cancelAll(): void {
// Bump all generations so in-flight doRefresh calls are invalidated.
// 增加所有 generations以便 in-flight doRefresh 调用无效。
for (const sessionId of generations.keys()) {
nextGeneration(sessionId)
}
for (const timer of timers.values()) {
clearTimeout(timer)
}
timers.clear()
failureCounts.clear()
}
return { schedule, scheduleFromExpiresIn, cancel, cancelAll }
}

View File

@@ -0,0 +1,117 @@
/**
* Bridge poll interval defaults. Extracted from pollConfig.ts so callers
* that don't need live GrowthBook tuning (daemon via Agent SDK) can avoid
* the growthbook.ts → config.ts → file.ts → sessionStorage.ts → commands.ts
* transitive dependency chain.
* Bridge 轮询间隔默认值。从 pollConfig.ts 提取,以便
* 不需要实时 GrowthBook 调整的调用者(通过 Agent SDK 的 daemon
* 可以避免 growthbook.ts → config.ts → file.ts → sessionStorage.ts → commands.ts
* 的传递依赖链。
*/
/**
* Poll interval when actively seeking work (no transport / below maxSessions).
* Governs user-visible "connecting…" latency on initial work pickup and
* recovery speed after the server re-dispatches a work item.
* 主动寻找工作时的轮询间隔(无传输 / 低于 maxSessions
* 控制初始工作拾取时用户可见的"connecting..."延迟和
* 服务器重新分配工作项后的恢复速度。
*/
const POLL_INTERVAL_MS_NOT_AT_CAPACITY = 2000
/**
* Poll interval when the transport is connected. Runs independently of
* heartbeat — when both are enabled, the heartbeat loop breaks out to poll
* at this interval. Set to 0 to disable at-capacity polling entirely.
*
* Server-side constraints that bound this value:
* - BRIDGE_LAST_POLL_TTL = 4h (Redis key expiry → environment auto-archived)
* - max_poll_stale_seconds = 24h (session-creation health gate, currently disabled)
*
* 10 minutes gives 24× headroom on the Redis TTL while still picking up
* server-initiated token-rotation redispatches within one poll cycle.
* The transport auto-reconnects internally for 10 minutes on transient WS
* failures, so poll is not the recovery path — it's strictly a liveness
* signal plus a backstop for permanent close.
* 传输连接时的轮询间隔。独立于 heartbeat 运行 — 当两者都启用时,
* heartbeat 循环中断并以此间隔轮询。设置为 0 可完全禁用满容量轮询。
*
* 约束此值的服务器端约束:
* - BRIDGE_LAST_POLL_TTL = 4hRedis 键过期 → 环境自动归档)
* - max_poll_stale_seconds = 24h会话创建健康检查当前禁用
*
* 10 分钟为 Redis TTL 提供 24 倍缓冲,同时仍能在一次轮询周期内
* 拾取服务器发起的 token 轮换重新分配。
* 传输在瞬态 WS 故障时内部自动重连 10 分钟,因此轮询不是恢复路径 —
* 它严格上是存活信号加上永久关闭的后备。
*/
const POLL_INTERVAL_MS_AT_CAPACITY = 600_000
/**
* Multisession bridge (bridgeMain.ts) poll intervals. Defaults match the
* single-session values so existing GrowthBook configs without these fields
* preserve current behavior. Ops can tune these independently via the
* tengu_bridge_poll_interval_config GB flag.
* 多会话 bridgebridgeMain.ts轮询间隔。默认值匹配
* 单会话值,以便没有这些字段的现有 GrowthBook 配置
* 保持当前行为。运营可以通过
* tengu_bridge_poll_interval_config GB 标志独立调整这些值。
*/
const MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY =
POLL_INTERVAL_MS_NOT_AT_CAPACITY
const MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY =
POLL_INTERVAL_MS_NOT_AT_CAPACITY
const MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY = POLL_INTERVAL_MS_AT_CAPACITY
export type PollIntervalConfig = {
poll_interval_ms_not_at_capacity: number
poll_interval_ms_at_capacity: number
non_exclusive_heartbeat_interval_ms: number
multisession_poll_interval_ms_not_at_capacity: number
multisession_poll_interval_ms_partial_capacity: number
multisession_poll_interval_ms_at_capacity: number
reclaim_older_than_ms: number
session_keepalive_interval_v2_ms: number
}
export const DEFAULT_POLL_CONFIG: PollIntervalConfig = {
poll_interval_ms_not_at_capacity: POLL_INTERVAL_MS_NOT_AT_CAPACITY,
poll_interval_ms_at_capacity: POLL_INTERVAL_MS_AT_CAPACITY,
// 0 = disabled. When > 0, at-capacity loops send per-work-item heartbeats
// at this interval. Independent of poll_interval_ms_at_capacity — both may
// run (heartbeat periodically yields to poll). 60s gives 5× headroom under
// the server's 300s heartbeat TTL. Named non_exclusive to distinguish from
// the old heartbeat_interval_ms field (either-or semantics in pre-#22145
// clients — heartbeat suppressed poll). Old clients ignore this key; ops
// can set both fields during rollout.
// 0 = 禁用。当 > 0 时,满容量循环以此间隔发送每个工作项的 heartbeat。
// 独立于 poll_interval_ms_at_capacity — 两者可能同时运行
//heartbeat 定期让渡给 poll。60s 为服务器的 300s heartbeat TTL 提供 5 倍缓冲。
// 命名为 non_exclusive 以区分旧的 heartbeat_interval_ms 字段
//pre-#22145 客户端中的 either-or 语义 — heartbeat 抑制 poll
// 旧客户端忽略此键;运营可以在推广期间设置这两个字段。
non_exclusive_heartbeat_interval_ms: 0,
multisession_poll_interval_ms_not_at_capacity:
MULTISESSION_POLL_INTERVAL_MS_NOT_AT_CAPACITY,
multisession_poll_interval_ms_partial_capacity:
MULTISESSION_POLL_INTERVAL_MS_PARTIAL_CAPACITY,
multisession_poll_interval_ms_at_capacity:
MULTISESSION_POLL_INTERVAL_MS_AT_CAPACITY,
// Poll query param: reclaim unacknowledged work items older than this.
// Matches the server's DEFAULT_RECLAIM_OLDER_THAN_MS (work_service.py:24).
// Enables picking up stale-pending work after JWT expiry, when the prior
// ack failed because the session_ingress_token was already stale.
// 轮询查询参数:回收早于此时间的未确认工作项。
// 匹配服务器的 DEFAULT_RECLAIM_OLDER_THAN_MS (work_service.py:24)。
// 使得在 JWT 过期后拾取 stale-pending 工作成为可能,当先前的
// ack 因为 session_ingress_token 已经过期而失败时。
reclaim_older_than_ms: 5000,
// 0 = disabled. When > 0, push a silent {type:'keep_alive'} frame to
// session-ingress at this interval so upstream proxies don't GC an idle
// remote-control session. 2 min is the default. _v2: bridge-only gate
// (pre-v2 clients read the old key, new clients ignore it).
// 0 = 禁用。当 > 0 时,以此间隔向 session-ingress 推送静默的 {type:'keep_alive'} 帧,
// 以便上游代理不会 GC 空闲的 remote-control 会话。默认 2 分钟。
// _v2: bridge 独有门禁pre-v2 客户端读取旧键,新客户端忽略它)。
session_keepalive_interval_v2_ms: 120_000,
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,47 @@
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.
* 到活动 REPL bridge handle 的全局指针,以便 useReplBridge 的 React 树之外的
* 调用者tools、slash commands可以调用 handle 方法如 subscribePR。
* 与 bridgeDebug.ts 相同的 one-bridge-per-process 理由 —
* handle 的闭包捕获了创建会话的 sessionId 和 getAccessToken
* 独立重新派生这些BriefTool/upload.ts 模式)有暂存/生产 token 分歧的风险。
*
* 在 init 完成时从 useReplBridge.tsx 设置;在拆卸时清除。
*/
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.
// 在会话记录中发布(或清除)我们的 bridge session ID以便其他
// 本地对等方可以从其 bridge 列表中排除我们 — 本地优先。
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.
* 我们自己的 bridge session ID采用 API 在 /v1/sessions 响应中返回的
* session_* compat 格式 — 如果 bridge 未连接则 undefined。
*/
export function getSelfBridgeCompatId(): string | undefined {
const h = getReplBridgeHandle()
return h ? toCompatSessionId(h.bridgeSessionId) : undefined
}

View File

@@ -0,0 +1,504 @@
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
import { CCRClient } from '../cli/transports/ccrClient.js'
import type { HybridTransport } from '../cli/transports/HybridTransport.js'
import { SSETransport } from '../cli/transports/SSETransport.js'
import { logForDebugging } from '../utils/debug.js'
import { errorMessage } from '../utils/errors.js'
import { updateSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
import type { SessionState } from '../utils/sessionState.js'
import { registerWorker } from './workSecret.js'
/**
* Transport abstraction for replBridge. Covers exactly the surface that
* replBridge.ts uses against HybridTransport so the v1/v2 choice is
* confined to the construction site.
*
* - v1: HybridTransport (WS reads + POST writes to Session-Ingress)
* - v2: SSETransport (reads) + CCRClient (writes to CCR v2 /worker/*)
*
* The v2 write path goes through CCRClient.writeEvent → SerialBatchEventUploader,
* NOT through SSETransport.write() — SSETransport.write() targets the
* Session-Ingress POST URL shape, which is wrong for CCR v2.
* replBridge 的传输抽象。覆盖 replBridge.ts 针对 HybridTransport 使用的
* 确切表面,以便 v1/v2 选择被限制在构造 site。
*
* - v1: HybridTransportWS 读取 + POST 写入 Session-Ingress
* - v2: SSETransport读取+ CCRClient写入 CCR v2 /worker/*
*
* v2 写入路径通过 CCRClient.writeEvent → SerialBatchEventUploader
* 不是通过 SSETransport.write() — SSETransport.write() 面向
* Session-Ingress POST URL 形状,这对 CCR v2 是错误的。
*/
export type ReplBridgeTransport = {
write(message: StdoutMessage): Promise<void>
writeBatch(messages: StdoutMessage[]): Promise<void>
close(): void
isConnectedStatus(): boolean
getStateLabel(): string
setOnData(callback: (data: string) => void): void
setOnClose(callback: (closeCode?: number) => void): void
setOnConnect(callback: () => void): void
connect(): void
/**
* High-water mark of the underlying read stream's event sequence numbers.
* replBridge reads this before swapping transports so the new one can
* resume from where the old one left off (otherwise the server replays
* the entire session history from seq 0).
*
* v1 returns 0 — Session-Ingress WS doesn't use SSE sequence numbers;
* replay-on-reconnect is handled by the server-side message cursor.
* 底层读取流的事件序列号的高水位标记。
* replBridge 在交换传输前读取此值,以便新的可以从旧的停止处继续
*(否则服务器从 seq 0 重放整个会话历史)。
*
* v1 返回 0 — Session-Ingress WS 不使用 SSE 序列号;
* 重连时的重放由服务器端消息游标处理。
*/
getLastSequenceNum(): number
/**
* Monotonic count of batches dropped via maxConsecutiveFailures.
* Snapshot before writeBatch() and compare after to detect silent drops
* (writeBatch() resolves normally even when batches were dropped).
* v2 returns 0 — the v2 write path doesn't set maxConsecutiveFailures.
* 通过 maxConsecutiveFailures 丢弃的批次单调计数。
* 在 writeBatch() 之前快照并在之后比较以检测静默丢弃
*即使批次被丢弃writeBatch() 也会正常解决)。
* v2 返回 0 — v2 写入路径不设置 maxConsecutiveFailures。
*/
readonly droppedBatchCount: number
/**
* PUT /worker state (v2 only; v1 is a no-op). `requires_action` tells
* the backend a permission prompt is pending — claude.ai shows the
* "waiting for input" indicator. REPL/daemon callers don't need this
* (user watches the REPL locally); multi-session worker callers do.
* PUT /worker state仅限 v2v1 是无操作)。`requires_action` 告诉
* 后端权限提示正在等待 — claude.ai 显示"waiting for input"指示器。
* REPL/daemon 调用者不需要这个(用户在本地观看 REPL
* 多会话 worker 调用者需要。
*/
reportState(state: SessionState): void
/** PUT /worker external_metadata (v2 only; v1 is a no-op). */
/** PUT /worker external_metadata仅限 v2v1 是无操作)。 */
reportMetadata(metadata: Record<string, unknown>): void
/**
* POST /worker/events/{id}/delivery (v2 only; v1 is a no-op). Populates
* CCR's processing_at/processed_at columns. `received` is auto-fired by
* CCRClient on every SSE frame and is not exposed here.
* POST /worker/events/{id}/delivery仅限 v2v1 是无操作)。填充
* CCR 的 processing_at/processed_at 列。`received` 由 CCRClient
* 在每个 SSE 帧上自动触发,不在这里暴露。
*/
reportDelivery(eventId: string, status: 'processing' | 'processed'): void
/**
* Drain the write queue before close() (v2 only; v1 resolves
* immediately — HybridTransport POSTs are already awaited per-write).
* 在 close() 之前排空写入队列(仅限 v2v1 立即解决 —
* HybridTransport POST 已经按写入等待)。
*/
flush(): Promise<void>
}
/**
* v1 adapter: HybridTransport already has the full surface (it extends
* WebSocketTransport which has setOnConnect + getStateLabel). This is a
* no-op wrapper that exists only so replBridge's `transport` variable
* has a single type.
* v1 适配器HybridTransport 已经有完整的表面(它扩展了
* 有 setOnConnect + getStateLabel 的 WebSocketTransport。这是一个
* 无操作包装器,仅存在以便 replBridge 的 `transport` 变量
* 有单一类型。
*/
export function createV1ReplTransport(
hybrid: HybridTransport,
): ReplBridgeTransport {
return {
write: msg => hybrid.write(msg),
writeBatch: msgs => hybrid.writeBatch(msgs),
close: () => hybrid.close(),
isConnectedStatus: () => hybrid.isConnectedStatus(),
getStateLabel: () => hybrid.getStateLabel(),
setOnData: cb => hybrid.setOnData(cb),
setOnClose: cb => hybrid.setOnClose(cb),
setOnConnect: cb => hybrid.setOnConnect(cb),
connect: () => void hybrid.connect(),
// v1 Session-Ingress WS doesn't use SSE sequence numbers; replay
// semantics are different. Always return 0 so the seq-num carryover
// logic in replBridge is a no-op for v1.
// v1 Session-Ingress WS 不使用 SSE 序列号;重放语义不同。
// 始终返回 0以便 replBridge 中的 seq-num 延续逻辑对 v1 无操作。
getLastSequenceNum: () => 0,
get droppedBatchCount() {
return hybrid.droppedBatchCount
},
reportState: () => {},
reportMetadata: () => {},
reportDelivery: () => {},
flush: () => Promise.resolve(),
}
}
/**
* v2 adapter: wrap SSETransport (reads) + CCRClient (writes, heartbeat,
* state, delivery tracking).
*
* Auth: v2 endpoints validate the JWT's session_id claim (register_worker.go:32)
* and worker role (environment_auth.py:856). OAuth tokens have neither.
* This is the inverse of the v1 replBridge path, which deliberately uses OAuth.
* The JWT is refreshed when the poll loop re-dispatches work — the caller
* invokes createV2ReplTransport again with the fresh token.
*
* Registration happens here (not in the caller) so the entire v2 handshake
* is one async step. registerWorker failure propagates — replBridge will
* catch it and stay on the poll loop.
* v2 适配器:包装 SSETransport读取+ CCRClient写入、heartbeat、
* 状态、交付跟踪)。
*
* Authv2 endpoints 验证 JWT 的 session_id claimregister_worker.go:32
* 和 worker 角色environment_auth.py:856。OAuth tokens 两者都没有。
* 这与 v1 replBridge 路径相反,后者故意使用 OAuth。
* 当 poll 循环重新分配工作时 JWT 被刷新 — 调用者
* 使用新的 token 再次调用 createV2ReplTransport。
*
* 注册在这里发生(不在调用者中),以便整个 v2 握手是一个异步步骤。
* registerWorker 失败会传播 — replBridge 会捕获它并留在 poll 循环上。
*/
export async function createV2ReplTransport(opts: {
sessionUrl: string
ingressToken: string
sessionId: string
/**
* SSE sequence-number high-water mark from the previous transport.
* Passed to the new SSETransport so its first connect() sends
* from_sequence_num / Last-Event-ID and the server resumes from where
* the old stream left off. Without this, every transport swap asks the
* server to replay the entire session history from seq 0.
* 来自先前传输的 SSE 序列号高水位标记。
* 传递给新的 SSETransport 以便其第一次 connect() 发送
* from_sequence_num / Last-Event-ID服务器从旧流停止处继续。
* 没有这个,每次传输交换都请求服务器从 seq 0 重放整个会话历史。
*/
initialSequenceNum?: number
/**
* Worker epoch from POST /bridge response. When provided, the server
* already bumped epoch (the /bridge call IS the register — see server
* PR #293280). When omitted (v1 CCR-v2 path via replBridge.ts poll loop),
* call registerWorker as before.
* 来自 POST /bridge 响应的 Worker epoch。当提供时服务器
* 已经增加了 epoch/bridge 调用就是注册 — 见服务器
* PR #293280。当省略时通过 replBridge.ts poll 循环的 v1 CCR-v2 路径),
* 像以前一样调用 registerWorker。
*/
epoch?: number
/** CCRClient heartbeat interval. Defaults to 20s when omitted. */
/** CCRClient heartbeat 间隔。省略时默认为 20s。 */
heartbeatIntervalMs?: number
/** ±fraction per-beat jitter. Defaults to 0 (no jitter) when omitted. */
/** 每次心跳的 ±fraction jitter。省略时默认为 0无 jitter。 */
heartbeatJitterFraction?: number
/**
* When true, skip opening the SSE read stream — only the CCRClient write
* path is activated. Use for mirror-mode attachments that forward events
* but never receive inbound prompts or control requests.
* 当为 true 时,跳过打开 SSE 读取流 — 仅激活 CCRClient 写入
* 路径。用于转发事件但从不接收入站 prompts 或控制请求的
* 镜像模式附件。
*/
outboundOnly?: boolean
/**
* Per-instance auth header source. When provided, CCRClient + SSETransport
* read auth from this closure instead of the process-wide
* CLAUDE_CODE_SESSION_ACCESS_TOKEN env var. Required for callers managing
* multiple concurrent sessions — the env-var path stomps across sessions.
* When omitted, falls back to the env var (single-session callers).
* 每实例 auth header 源。当提供时CCRClient + SSETransport
* 从这个闭包读取 auth而不是进程范围的
* CLAUDE_CODE_SESSION_ACCESS_TOKEN env var。需要用于管理
* 多个并发会话的调用者 — env-var 路径会踩过会话。
* 当省略时,回退到 env var单会话调用者
*/
getAuthToken?: () => string | undefined
}): Promise<ReplBridgeTransport> {
const {
sessionUrl,
ingressToken,
sessionId,
initialSequenceNum,
getAuthToken,
} = opts
// Auth header builder. If getAuthToken is provided, read from it
// (per-instance, multi-session safe). Otherwise write ingressToken to
// the process-wide env var (legacy single-session path — CCRClient's
// default getAuthHeaders reads it via getSessionIngressAuthHeaders).
// Auth header 构建器。如果提供了 getAuthToken从它读取
//(每实例,多会话安全)。否则将 ingressToken 写入
// 进程范围的 env var遗留单会话路径 — CCRClient 的
// 默认 getAuthHeaders 通过 getSessionIngressAuthHeaders 读取它)。
let getAuthHeaders: (() => Record<string, string>) | undefined
if (getAuthToken) {
getAuthHeaders = (): Record<string, string> => {
const token = getAuthToken()
if (!token) return {}
return { Authorization: `Bearer ${token}` }
}
} else {
// CCRClient.request() and SSETransport.connect() both read auth via
// getSessionIngressAuthHeaders() → this env var. Set it before either
// touches the network.
// CCRClient.request() 和 SSETransport.connect() 都通过
// getSessionIngressAuthHeaders() → 这个 env var 读取 auth。在任一者
// 接触网络之前设置它。
updateSessionIngressAuthToken(ingressToken)
}
const epoch = opts.epoch ?? (await registerWorker(sessionUrl, ingressToken))
logForDebugging(
`[bridge:repl] CCR v2: worker sessionId=${sessionId} epoch=${epoch}${opts.epoch !== undefined ? ' (from /bridge)' : ' (via registerWorker)'}`,
)
// Derive SSE stream URL. Same logic as transportUtils.ts:26-33 but
// starting from an http(s) base instead of a --sdk-url that might be ws://.
// 派生 SSE 流 URL。与 transportUtils.ts:26-33 相同的逻辑,但
// 从 http(s) 基础开始,而非可能是 ws:// 的 --sdk-url。
const sseUrl = new URL(sessionUrl)
sseUrl.pathname = sseUrl.pathname.replace(/\/$/, '') + '/worker/events/stream'
const sse = new SSETransport(
sseUrl,
{},
sessionId,
undefined,
initialSequenceNum,
getAuthHeaders,
)
let onCloseCb: ((closeCode?: number) => void) | undefined
const ccr = new CCRClient(sse, new URL(sessionUrl), {
getAuthHeaders,
heartbeatIntervalMs: opts.heartbeatIntervalMs,
heartbeatJitterFraction: opts.heartbeatJitterFraction,
// Default is process.exit(1) — correct for spawn-mode children. In-process,
// that kills the REPL. Close instead: replBridge's onClose wakes the poll
// loop, which picks up the server's re-dispatch (with fresh epoch).
// 默认为 process.exit(1) — 对 spawn-mode 子进程正确。在进程中,
// 这会杀死 REPL。改为关闭replBridge 的 onClose 唤醒 poll
// 循环,后者拾取服务器的重新分配(带 fresh epoch
onEpochMismatch: () => {
logForDebugging(
'[bridge:repl] CCR v2: epoch superseded (409) — closing for poll-loop recovery',
)
// Close resources in a try block so the throw always executes.
// If ccr.close() or sse.close() throw, we still need to unwind
// the caller (request()) — otherwise handleEpochMismatch's `never`
// return type is violated at runtime and control falls through.
// 在 try 块中关闭资源,以便 throw 始终执行。
// 如果 ccr.close() 或 sse.close() throw我们仍然需要解开
// 调用者request())— 否则 handleEpochMismatch 的 `never`
// 返回类型在运行时被违反,控制流掉落。
try {
ccr.close()
sse.close()
onCloseCb?.(4090)
} catch (closeErr: unknown) {
logForDebugging(
`[bridge:repl] CCR v2: error during epoch-mismatch cleanup: ${errorMessage(closeErr)}`,
{ level: 'error' },
)
}
// Don't return — the calling request() code continues after the 409
// branch, so callers see the logged warning and a false return. We
// throw to unwind; the uploaders catch it as a send failure.
// 不要返回 — 调用 request() 代码在 409 分支后继续,
// 因此调用者看到日志警告和 false 返回。我们 throw 以解开;
// uploaders 将其作为发送失败捕获。
throw new Error('epoch superseded')
},
})
// CCRClient's constructor wired sse.setOnEvent → reportDelivery('received').
// remoteIO.ts additionally sends 'processing'/'processed' via
// setCommandLifecycleListener, which the in-process query loop fires. This
// transport's only caller (replBridge/daemonBridge) has no such wiring —
// the daemon's agent child is a separate process (ProcessTransport), and its
// notifyCommandLifecycle calls fire with listener=null in its own module
// scope. So events stay at 'received' forever, and reconnectSession re-queues
// them on every daemon restart (observed: 21→24→25 phantom prompts as
// "user sent a new message while you were working" system-reminders).
//
// Fix: ACK 'processed' immediately alongside 'received'. The window between
// SSE receipt and transcript-write is narrow (queue → SDK → child stdin →
// model); a crash there loses one prompt vs. the observed N-prompt flood on
// every restart. Overwrite the constructor's wiring to do both — setOnEvent
// replaces, not appends (SSETransport.ts:658).
// CCRClient 的构造函数连接了 sse.setOnEvent → reportDelivery('received')。
// remoteIO.ts 额外通过 setCommandLifecycleListener 发送 'processing'/'processed'
// 这是 in-process 查询循环触发的。此传输的唯一调用者
//replBridge/daemonBridge没有这样的连接 — daemon 的 agent 子进程
// 是一个单独的进程ProcessTransport其 notifyCommandLifecycle 调用
// 在自己的模块范围内用 listener=null 触发。因此事件永远停留在
// 'received'reconnectSession 在每次 daemon 重启时重新排队
//观察到21→24→25 幻影 prompts 为"user sent a new message while you were working"
// 系统提醒)。
//
// 修复:立即与 'received' 一起 ACK 'processed'。SSE 接收和 transcript 写入
// 之间的窗口很窄queue → SDK → child stdin → model
// 在那里崩溃会丢失一个 prompt 对比每次重启时观察到的 N-prompt 洪水。
// 覆盖构造函数的连接以同时做两者 — setOnEvent 替换而非附加
//SSETransport.ts:658
sse.setOnEvent(event => {
ccr.reportDelivery(event.event_id, 'received')
ccr.reportDelivery(event.event_id, 'processed')
})
// Both sse.connect() and ccr.initialize() are deferred to connect() below.
// replBridge's calling order is newTransport → setOnConnect → setOnData →
// setOnClose → connect(), and both calls need those callbacks wired first:
// sse.connect() opens the stream (events flow to onData/onClose immediately),
// and ccr.initialize().then() fires onConnectCb.
//
// onConnect fires once ccr.initialize() resolves. Writes go via
// CCRClient HTTP POST (SerialBatchEventUploader), not SSE, so the
// write path is ready the moment workerEpoch is set. SSE.connect()
// awaits its read loop and never resolves — don't gate on it.
// The SSE stream opens in parallel (~30ms) and starts delivering
// inbound events via setOnData; outbound doesn't need to wait for it.
// sse.connect() 和 ccr.initialize() 都延迟到下面的 connect()。
// replBridge 的调用顺序是 newTransport → setOnConnect → setOnData →
// setOnClose → connect(),两个调用都需要那些回调先连接:
// sse.connect() 打开流(事件立即流向 onData/onClose
// ccr.initialize().then() 触发 onConnectCb。
//
// onConnect 在 ccr.initialize() 解决后触发一次。写入通过
// CCRClient HTTP POSTSerialBatchEventUploader而非 SSE
// 因此写入路径在 workerEpoch 设置时就准备好了。
// SSE.connect() 等待其读取循环且从不解决 — 不要门控它。
// SSE 流并行打开(~30ms并开始通过 setOnData 传递
// 入站事件;出站不需要等待它。
let onConnectCb: (() => void) | undefined
let ccrInitialized = false
let closed = false
return {
write(msg) {
return ccr.writeEvent(msg)
},
async writeBatch(msgs) {
// SerialBatchEventUploader already batches internally (maxBatchSize=100);
// sequential enqueue preserves order and the uploader coalesces.
// Check closed between writes to avoid sending partial batches after
// transport teardown (epoch mismatch, SSE drop).
// SerialBatchEventUploader 已经在内部批处理maxBatchSize=100
// 顺序入队保持顺序uploader 合并。
// 在写入之间检查 closed 以避免在传输拆除后发送部分批次
//epoch 不匹配、SSE 丢弃)。
for (const m of msgs) {
if (closed) break
await ccr.writeEvent(m)
}
},
close() {
closed = true
ccr.close()
sse.close()
},
isConnectedStatus() {
// Write-readiness, not read-readiness — replBridge checks this
// before calling writeBatch. SSE open state is orthogonal.
// 写入就绪,而非读取就绪 — replBridge 在调用 writeBatch 前检查这个。
// SSE 打开状态是正交的。
return ccrInitialized
},
getStateLabel() {
// SSETransport doesn't expose its state string; synthesize from
// what we can observe. replBridge only uses this for debug logging.
// SSETransport 不暴露其状态字符串;从我们能观察的合成。
// replBridge 只将此用于调试日志记录。
if (sse.isClosedStatus()) return 'closed'
if (sse.isConnectedStatus()) return ccrInitialized ? 'connected' : 'init'
return 'connecting'
},
setOnData(cb) {
sse.setOnData(cb)
},
setOnClose(cb) {
onCloseCb = cb
// SSE reconnect-budget exhaustion fires onClose(undefined) — map to
// 4092 so ws_closed telemetry can distinguish it from HTTP-status
// closes (SSETransport:280 passes response.status). Stop CCRClient's
// heartbeat timer before notifying replBridge. (sse.close() doesn't
// invoke this, so the epoch-mismatch path above isn't double-firing.)
// SSE 重连预算耗尽触发 onClose(undefined) — 映射到 4092 以便
// ws_closed 遥测可以将其与 HTTP 状态关闭区分开来
//SSETransport:280 传递 response.status。在通知 replBridge 之前
// 停止 CCRClient 的 heartbeat 计时器。sse.close() 不调用这个,
// 因此上面的 epoch-mismatch 路径不会双重触发。)
sse.setOnClose(code => {
ccr.close()
cb(code ?? 4092)
})
},
setOnConnect(cb) {
onConnectCb = cb
},
getLastSequenceNum() {
return sse.getLastSequenceNum()
},
// v2 write path (CCRClient) doesn't set maxConsecutiveFailures — no drops.
// v2 写入路径CCRClient不设置 maxConsecutiveFailures — 无丢弃。
droppedBatchCount: 0,
reportState(state) {
ccr.reportState(state)
},
reportMetadata(metadata) {
ccr.reportMetadata(metadata)
},
reportDelivery(eventId, status) {
ccr.reportDelivery(eventId, status)
},
flush() {
return ccr.flush()
},
connect() {
// Outbound-only: skip the SSE read stream entirely — no inbound
// events to receive, no delivery ACKs to send. Only the CCRClient
// write path (POST /worker/events) and heartbeat are needed.
// 出站专用:完全跳过 SSE 读取流 — 无入站事件接收,
// 无交付 ACK 发送。只需要 CCRClient 写入路径
//POST /worker/events和 heartbeat。
if (!opts.outboundOnly) {
// Fire-and-forget — SSETransport.connect() awaits readStream()
// (the read loop) and only resolves on stream close/error. The
// spawn-mode path in remoteIO.ts does the same void discard.
// Fire-and-forget — SSETransport.connect() 等待 readStream()
//(读取循环)且只在流关闭/错误时解决。remoteIO.ts 中的
// spawn-mode 路径同样做 void discard。
void sse.connect()
}
void ccr.initialize(epoch).then(
() => {
ccrInitialized = true
logForDebugging(
`[bridge:repl] v2 transport ready for writes (epoch=${epoch}, sse=${sse.isConnectedStatus() ? 'open' : 'opening'})`,
)
onConnectCb?.()
},
(err: unknown) => {
logForDebugging(
`[bridge:repl] CCR v2 initialize failed: ${errorMessage(err)}`,
{ level: 'error' },
)
// Close transport resources and notify replBridge via onClose
// so the poll loop can retry on the next work dispatch.
// Without this callback, replBridge never learns the transport
// failed to initialize and sits with transport === null forever.
// 关闭传输资源并通过 onClose 通知 replBridge
// 以便 poll 循环可以在下次工作分配时重试。
// 没有这个回调replBridge 永远不会知道传输
// 初始化失败并永远以 transport === null 坐着。
ccr.close()
sse.close()
onCloseCb?.(4091) // 4091 = init failure, distinguishable from 4090 epoch mismatch
},
)
},
}
}

View File

@@ -0,0 +1,89 @@
/**
* Session ID tag translation helpers for the CCR v2 compat layer.
*
* Lives in its own file (rather than workSecret.ts) so that sessionHandle.ts
* and replBridgeTransport.ts (bridge.mjs entry points) can import from
* workSecret.ts without pulling in these retag functions.
*
* The isCseShimEnabled kill switch is injected via setCseShimGate() to avoid
* a static import of bridgeEnabled.ts → growthbook.ts → config.ts — all
* banned from the sdk.mjs bundle (scripts/build-agent-sdk.sh). Callers that
* already import bridgeEnabled.ts register the gate; the SDK path never does,
* so the shim defaults to active (matching isCseShimEnabled()'s own default).
* CCR v2 compat 层的会话 ID 标签转换辅助函数。
*
* 生活在自己的文件中(而非 workSecret.ts以便 sessionHandle.ts
* 和 replBridgeTransport.tsbridge.mjs 入口点)可以从 workSecret.ts 导入,
* 而无需引入这些 retag 函数。
*
* isCseShimEnabled kill switch 通过 setCseShimGate() 注入,以避免
* bridgeEnabled.ts → growthbook.ts → config.ts 的静态导入 — 所有这些
* 都从 sdk.mjs bundle 中禁止scripts/build-agent-sdk.sh。已经导入
* bridgeEnabled.ts 的调用者注册门禁SDK 路径从不这样做,
* 因此 shim 默认为 active匹配 isCseShimEnabled() 自身的默认值)。
*/
let _isCseShimEnabled: (() => boolean) | undefined
/**
* Register the GrowthBook gate for the cse_ shim. Called from bridge
* init code that already imports bridgeEnabled.ts.
* 为 cse_ shim 注册 GrowthBook 门禁。从已经导入 bridgeEnabled.ts 的
* bridge init 代码调用。
*/
export function setCseShimGate(gate: () => boolean): void {
_isCseShimEnabled = gate
}
/**
* Re-tag a `cse_*` session ID to `session_*` for use with the v1 compat API.
*
* Worker endpoints (/v1/code/sessions/{id}/worker/*) want `cse_*`; that's
* what the work poll delivers. Client-facing compat endpoints
* (/v1/sessions/{id}, /v1/sessions/{id}/archive, /v1/sessions/{id}/events)
* want `session_*` — compat/convert.go:27 validates TagSession. Same UUID,
* different costume. No-op for IDs that aren't `cse_*`.
*
* bridgeMain holds one sessionId variable for both worker registration and
* session-management calls. It arrives as `cse_*` from the work poll under
* the compat gate, so archiveSession/fetchSessionTitle need this re-tag.
* 将 `cse_*` session ID 重新标记为 `session_*`,用于 v1 compat API。
*
* Worker endpoints/v1/code/sessions/{id}/worker/*)需要 `cse_*`
* 这是 work poll 提供的内容。面向客户端的 compat endpoints
*/v1/sessions/{id}, /v1/sessions/{id}/archive, /v1/sessions/{id}/events
* 需要 `session_*` — compat/convert.go:27 验证 TagSession。同一个 UUID
* 不同的 costume。对于不是 `cse_*` 的 ID 无操作。
*
* bridgeMain 为 worker 注册和会话管理调用持有一个 sessionId 变量。
* 在 compat 门禁下,它作为 `cse_*` 从 work poll 到达,
* 因此 archiveSession/fetchSessionTitle 需要这个重新标记。
*/
export function toCompatSessionId(id: string): string {
if (!id.startsWith('cse_')) return id
if (_isCseShimEnabled && !_isCseShimEnabled()) return id
return 'session_' + id.slice('cse_'.length)
}
/**
* Re-tag a `session_*` session ID to `cse_*` for infrastructure-layer calls.
*
* Inverse of toCompatSessionId. POST /v1/environments/{id}/bridge/reconnect
* lives below the compat layer: once ccr_v2_compat_enabled is on server-side,
* it looks sessions up by their infra tag (`cse_*`). createBridgeSession still
* returns `session_*` (compat/convert.go:41) and that's what bridge-pointer
* stores — so perpetual reconnect passes the wrong costume and gets "Session
* not found" back. Same UUID, wrong tag. No-op for IDs that aren't `session_*`.
* 将 `session_*` session ID 重新标记为 `cse_*`,用于基础设施层调用。
*
* toCompatSessionId 的反向。POST /v1/environments/{id}/bridge/reconnect
* 位于 compat 层之下:一旦 ccr_v2_compat_enabled 在服务器端开启,
* 它通过 infra tag`cse_*`查找会话。createBridgeSession 仍然返回
* `session_*`compat/convert.go:41这是 bridge-pointer 存储的 —
* 因此永久 reconnect 传递了错误的 costume 并得到"Session not found"。
* 同一个 UUID错误的标签。对于不是 `session_*` 的 ID 无操作。
*/
export function toInfraSessionId(id: string): string {
if (!id.startsWith('session_')) return id
return 'cse_' + id.slice('session_'.length)
}

View File

@@ -0,0 +1,260 @@
/** 默认的每会话超时时间24 小时)。 */
export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000
/** 附加到 bridge 认证错误的可重用登录指导。 */
export const BRIDGE_LOGIN_INSTRUCTION =
'Remote Control 仅适用于 claude.ai 订阅。请使用 `/login` 使用您的 claude.ai 账户登录。'
/** 运行 `claude remote-control` 但未认证时打印的完整错误。 */
export const BRIDGE_LOGIN_ERROR =
'Error: You must be logged in to use Remote Control.\n\n' +
BRIDGE_LOGIN_INSTRUCTION
/** 当用户断开 Remote Control 连接时显示(通过 /remote-control 或 ultraplan 启动)。 */
export const REMOTE_CONTROL_DISCONNECTED_MSG = 'Remote Control disconnected.'
// --- environments API 的协议类型 ---
export type WorkData = {
type: 'session' | 'healthcheck'
id: string
}
export type WorkResponse = {
id: string
type: 'work'
environment_id: string
state: string
data: WorkData
secret: string // base64url 编码的 JSON
created_at: string
}
export type WorkSecret = {
version: number
session_ingress_token: string
api_base_url: string
sources: Array<{
type: string
git_info?: { type: string; repo: string; ref?: string; token?: string }
}>
auth: Array<{ type: string; token: string }>
claude_code_args?: Record<string, string> | null
mcp_config?: unknown | null
environment_variables?: Record<string, string> | null
/**
* 服务端驱动的 CCR v2 选择器。当会话通过 v2 兼容层创建时
*ccr_v2_compat_enabled由 prepare_work_secret() 设置。
* 与 BYOC 运行者在 environment-runner/sessionExecutor.ts 中读取的相同字段。
*/
use_code_sessions?: boolean
}
export type SessionDoneStatus = 'completed' | 'failed' | 'interrupted'
export type SessionActivityType = 'tool_start' | 'text' | 'result' | 'error'
export type SessionActivity = {
type: SessionActivityType
summary: string // 例如 "Editing src/foo.ts", "Reading package.json"
timestamp: number
}
/**
* `claude remote-control` 选择会话工作目录的方式。
* - `single-session`cwd 中一个会话bridge 在结束时拆除
* - `worktree`:持久化服务器,每个会话获得隔离的 git worktree
* - `same-dir`:持久化服务器,每个会话共享 cwd可能互相覆盖
*/
export type SpawnMode = 'single-session' | 'worktree' | 'same-dir'
/**
* 此代码库产生的 well-known worker_type 值。作为
* `metadata.worker_type` 在环境注册时发送,以便 claude.ai 可以按来源过滤
* 会话选择器(例如 assistant 选项卡只显示 assistant
* workers。后端将这视为不透明字符串 — desktop cowork
* 发送 `"cowork"`不在此联合类型中。REPL 代码使用此窄类型
* 进行穷尽性检查wire 级字段接受任何字符串。
*/
export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant'
export type BridgeConfig = {
dir: string
machineName: string
branch: string
gitRepoUrl: string | null
maxSessions: number
spawnMode: SpawnMode
verbose: boolean
sandbox: boolean
/** 客户端生成的 UUID标识此 bridge 实例。 */
bridgeId: string
/**
* 作为 metadata.worker_type 发送,以便 Web 客户端可以按来源过滤。
* 后端将其视为不透明 — 任何字符串,不仅仅是 BridgeWorkerType。
*/
workerType: string
/** 客户端生成的 UUID用于幂等环境注册。 */
environmentId: string
/**
* 要重用的后端颁发的 environment_id。当设置时后端将注册视为
* 重新连接到现有环境而不是创建新环境。由 `claude remote-control
* --session-id` 恢复使用。必须是后端格式的 ID — 客户端 UUID
* 会被拒绝并返回 400。
*/
reuseEnvironmentId?: string
/** bridge 连接到的 API 基础 URL用于轮询。 */
apiBaseUrl: string
/** WebSocket 连接的会话入口基础 URL本地可能与 apiBaseUrl 不同)。 */
sessionIngressUrl: string
/** 通过 --debug-file 传递的调试文件路径。 */
debugFile?: string
/** 以毫秒为单位的每会话超时。超过此时间的会话将被终止。 */
sessionTimeoutMs?: number
}
// --- 依赖接口(用于测试)---
/**
* 发送回会话的 control_response 事件(例如权限决定)。
* `subtype` 是 SDK 协议的 `'success'`;内部的 `response`
* 携带权限决定 payload例如 `{ behavior: 'allow' }`)。
*/
export type PermissionResponseEvent = {
type: 'control_response'
response: {
subtype: 'success'
request_id: string
response: Record<string, unknown>
}
}
export type BridgeApiClient = {
registerBridgeEnvironment(config: BridgeConfig): Promise<{
environment_id: string
environment_secret: string
}>
pollForWork(
environmentId: string,
environmentSecret: string,
signal?: AbortSignal,
reclaimOlderThanMs?: number,
): Promise<WorkResponse | null>
acknowledgeWork(
environmentId: string,
workId: string,
sessionToken: string,
): Promise<void>
/** 通过 environments API 停止工作项。 */
stopWork(environmentId: string, workId: string, force: boolean): Promise<void>
/** 优雅关闭时注销/删除 bridge 环境。 */
deregisterEnvironment(environmentId: string): Promise<void>
/** 通过会话事件 API 发送权限响应control_response到会话。 */
sendPermissionResponseEvent(
sessionId: string,
event: PermissionResponseEvent,
sessionToken: string,
): Promise<void>
/** 归档会话,使其在服务器上不再显示为活动状态。 */
archiveSession(sessionId: string): Promise<void>
/**
* 强制停止过时的 worker 实例并在环境中重新排队会话。
* 用于 `--session-id` 在原始 bridge 死亡后恢复会话。
*/
reconnectSession(environmentId: string, sessionId: string): Promise<void>
/**
* 为活动工作项发送轻量级心跳,延长其租约。
* 使用 SessionIngressAuthJWT无数据库命中而不是 EnvironmentSecretAuth。
* 返回服务器的响应及租约状态。
*/
heartbeatWork(
environmentId: string,
workId: string,
sessionToken: string,
): Promise<{ lease_extended: boolean; state: string }>
}
export type SessionHandle = {
sessionId: string
done: Promise<SessionDoneStatus>
kill(): void
forceKill(): void
activities: SessionActivity[] // 最近活动的环形缓冲区(约最后 10 条)
currentActivity: SessionActivity | null // 最近的活动
accessToken: string // 用于 API 调用的 session_ingress_token
lastStderr: string[] // 最后 stderr 行的环形缓冲区
writeStdin(data: string): void // 直接写入子进程 stdin
/** 更新运行中会话的访问令牌(例如令牌刷新后)。 */
updateAccessToken(token: string): void
}
export type SessionSpawnOpts = {
sessionId: string
sdkUrl: string
accessToken: string
/** 为 true 时,使用 CCR v2 环境变量SSE 传输 + CCRClient生成子进程。 */
useCcrV2?: boolean
/** 当 useCcrV2 为 true 时必填。从 POST /worker/register 获取。 */
workerEpoch?: number
/**
* 在看到第一个真实用户消息的文本时触发一次(通过 --replay-user-messages
* 允许调用者在尚不存在时派生会话标题。工具结果和合成用户
* 消息被跳过。
*/
onFirstUserMessage?: (text: string) => void
}
export type SessionSpawner = {
spawn(opts: SessionSpawnOpts, dir: string): SessionHandle
}
export type BridgeLogger = {
printBanner(config: BridgeConfig, environmentId: string): void
logSessionStart(sessionId: string, prompt: string): void
logSessionComplete(sessionId: string, durationMs: number): void
logSessionFailed(sessionId: string, error: string): void
logStatus(message: string): void
logVerbose(message: string): void
logError(message: string): void
/** 记录重新连接成功事件(从连接错误恢复后)。 */
logReconnected(disconnectedMs: number): void
/** 显示空闲状态,包含 repo/branch 信息和微光动画。 */
updateIdleStatus(): void
/** 在实时显示中显示重新连接状态。 */
updateReconnectingStatus(delayStr: string, elapsedStr: string): void
updateSessionStatus(
sessionId: string,
elapsed: string,
activity: SessionActivity,
trail: string[],
): void
clearStatus(): void
/** 设置状态行显示的仓库信息。 */
setRepoInfo(repoName: string, branch: string): void
/** 设置状态行上方显示的调试日志 globant 用户)。 */
setDebugLogPath(path: string): void
/** 当会话启动时转换到 "Attached" 状态。 */
setAttached(sessionId: string): void
/** 在实时显示中显示失败状态。 */
updateFailedStatus(error: string): void
/** 切换二维码可见性。 */
toggleQr(): void
/** 更新 "<n> of <m> sessions" 指示器和生成模式提示。 */
updateSessionCount(active: number, max: number, mode: SpawnMode): void
/** 更新会话计数行中显示的生成模式。传入 null 以隐藏(单会话或切换不可用)。 */
setSpawnModeDisplay(mode: 'same-dir' | 'worktree' | null): void
/** 为多会话显示注册新会话(在生成成功后调用)。 */
addSession(sessionId: string, url: string): void
/** 更新多会话列表中每个会话的活动摘要(正在运行的工具)。 */
updateSessionActivity(sessionId: string, activity: SessionActivity): void
/**
* 设置会话的显示标题。在多会话模式下,更新要点列表
* 条目。在单会话模式下,也在主状态行中显示标题。
* 触发渲染(防止在 reconnecting/failed 状态下渲染)。
*/
setSessionTitle(sessionId: string, title: string): void
/** 在会话结束时从多会话显示中移除会话。 */
removeSession(sessionId: string): void
/** 强制重新渲染状态显示(用于多会话活动刷新)。 */
refreshDisplay(): void
}

View File

@@ -0,0 +1,37 @@
import { feature } from 'bun:bundle'
import type { Message } from '../types/message.js'
import type { Attachment } from '../utils/attachments.js'
import { getGlobalConfig } from '../utils/config.js'
import { getCompanion } from './companion.js'
export function companionIntroText(name: string, species: string): string {
return `# Companion
A small ${species} named ${name} sits beside the user's input box and occasionally comments in a speech bubble. You're not ${name} — it's a separate watcher.
When the user addresses ${name} directly (by name), its bubble will answer. Your job in that moment is to stay out of the way: respond in ONE line or less, or just answer any part of the message meant for you. Don't explain that you're not ${name} — they know. Don't narrate what ${name} might say — the bubble handles that.`
}
export function getCompanionIntroAttachment(
messages: Message[] | undefined,
): Attachment[] {
if (!feature('BUDDY')) return []
const companion = getCompanion()
if (!companion || getGlobalConfig().companionMuted) return []
// Skip if already announced for this companion.
// 如果此 companion 已 announcement则跳过。
for (const msg of messages ?? []) {
if (msg.type !== 'attachment') continue
if (msg.attachment.type !== 'companion_intro') continue
if (msg.attachment.name === companion.name) return []
}
return [
{
type: 'companion_intro',
name: companion.name,
species: companion.species,
},
]
}

View File

@@ -0,0 +1,141 @@
import { getGlobalConfig } from '../utils/config.js'
import {
type Companion,
type CompanionBones,
EYES,
HATS,
RARITIES,
RARITY_WEIGHTS,
type Rarity,
SPECIES,
STAT_NAMES,
type StatName,
} from './types.js'
// Mulberry32 — tiny seeded PRNG, good enough for picking ducks
// Mulberry32 — 小型 seeded PRNG用于挑选 ducks 足够好
function mulberry32(seed: number): () => number {
let a = seed >>> 0
return function () {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
function hashString(s: string): number {
if (typeof Bun !== 'undefined') {
return Number(BigInt(Bun.hash(s)) & 0xffffffffn)
}
let h = 2166136261
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
function pick<T>(rng: () => number, arr: readonly T[]): T {
return arr[Math.floor(rng() * arr.length)]!
}
function rollRarity(rng: () => number): Rarity {
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
let roll = rng() * total
for (const rarity of RARITIES) {
roll -= RARITY_WEIGHTS[rarity]
if (roll < 0) return rarity
}
return 'common'
}
const RARITY_FLOOR: Record<Rarity, number> = {
common: 5,
uncommon: 15,
rare: 25,
epic: 35,
legendary: 50,
}
// One peak stat, one dump stat, rest scattered. Rarity bumps the floor.
// 一个峰值属性一个低谷属性其余分散。Rarity 提高下限。
function rollStats(
rng: () => number,
rarity: Rarity,
): Record<StatName, number> {
const floor = RARITY_FLOOR[rarity]
const peak = pick(rng, STAT_NAMES)
let dump = pick(rng, STAT_NAMES)
while (dump === peak) dump = pick(rng, STAT_NAMES)
const stats = {} as Record<StatName, number>
for (const name of STAT_NAMES) {
if (name === peak) {
stats[name] = Math.min(100, floor + 50 + Math.floor(rng() * 30))
} else if (name === dump) {
stats[name] = Math.max(1, floor - 10 + Math.floor(rng() * 15))
} else {
stats[name] = floor + Math.floor(rng() * 40)
}
}
return stats
}
const SALT = 'friend-2026-401'
export type Roll = {
bones: CompanionBones
inspirationSeed: number
}
function rollFrom(rng: () => number): Roll {
const rarity = rollRarity(rng)
const bones: CompanionBones = {
rarity,
species: pick(rng, SPECIES),
eye: pick(rng, EYES),
hat: rarity === 'common' ? 'none' : pick(rng, HATS),
shiny: rng() < 0.01,
stats: rollStats(rng, rarity),
}
return { bones, inspirationSeed: Math.floor(rng() * 1e9) }
}
// Called from three hot paths (500ms sprite tick, per-keystroke PromptInput,
// per-turn observer) with the same userId → cache the deterministic result.
// 从三个 hot paths 调用500ms sprite tick、per-keystroke PromptInput、
// per-turn observer使用相同的 userId → 缓存确定性结果。
let rollCache: { key: string; value: Roll } | undefined
export function roll(userId: string): Roll {
const key = userId + SALT
if (rollCache?.key === key) return rollCache.value
const value = rollFrom(mulberry32(hashString(key)))
rollCache = { key, value }
return value
}
export function rollWithSeed(seed: string): Roll {
return rollFrom(mulberry32(hashString(seed)))
}
export function companionUserId(): string {
const config = getGlobalConfig()
return config.oauthAccount?.accountUuid ?? config.userID ?? 'anon'
}
// Regenerate bones from userId, merge with stored soul. Bones never persist
// so species renames and SPECIES-array edits can't break stored companions,
// and editing config.companion can't fake a rarity.
// 从 userId 重新生成 bones与存储的 soul 合并。Bones 从不持久化,
// 因此 species 重命名和 SPECIES-array 编辑不会破坏存储的 companions
// 并且编辑 config.companion 无法伪造 rarity。
export function getCompanion(): Companion | undefined {
const stored = getGlobalConfig().companion
if (!stored) return undefined
const { bones } = roll(companionUserId())
// bones last so stale bones fields in old-format configs get overridden
// bones 最后,以便旧格式配置中的陈旧 bones 字段被覆盖
return { ...stored, ...bones }
}

View File

@@ -0,0 +1,521 @@
import type { CompanionBones, Eye, Hat, Species } from './types.js'
import {
axolotl,
blob,
cactus,
capybara,
cat,
chonk,
dragon,
duck,
ghost,
goose,
mushroom,
octopus,
owl,
penguin,
rabbit,
robot,
snail,
turtle,
} from './types.js'
// Each sprite is 5 lines tall, 12 wide (after {E}→1char substitution).
// Multiple frames per species for idle fidget animation.
// Line 0 is the hat slot — must be blank in frames 0-1; frame 2 may use it.
// 每个 sprite 高 5 行,宽 12 列({E}→1字符替换后
// 每个 species 有多帧用于空闲 fidget 动画。
// 第 0 行是帽子槽位 — 在帧 0-1 中必须为空;帧 2 可能使用它。
const BODIES: Record<Species, string[][]> = {
[duck]: [
[
' ',
' __ ',
' <({E} )___ ',
' ( ._> ',
' `--´ ',
],
[
' ',
' __ ',
' <({E} )___ ',
' ( ._> ',
' `--´~ ',
],
[
' ',
' __ ',
' <({E} )___ ',
' ( .__> ',
' `--´ ',
],
],
[goose]: [
[
' ',
' ({E}> ',
' || ',
' _(__)_ ',
' ^^^^ ',
],
[
' ',
' ({E}> ',
' || ',
' _(__)_ ',
' ^^^^ ',
],
[
' ',
' ({E}>> ',
' || ',
' _(__)_ ',
' ^^^^ ',
],
],
[blob]: [
[
' ',
' .----. ',
' ( {E} {E} ) ',
' ( ) ',
' `----´ ',
],
[
' ',
' .------. ',
' ( {E} {E} ) ',
' ( ) ',
' `------´ ',
],
[
' ',
' .--. ',
' ({E} {E}) ',
' ( ) ',
' `--´ ',
],
],
[cat]: [
[
' ',
' /\\_/\\ ',
' ( {E} {E}) ',
' ( ω ) ',
' (")_(") ',
],
[
' ',
' /\\_/\\ ',
' ( {E} {E}) ',
' ( ω ) ',
' (")_(")~ ',
],
[
' ',
' /\\-/\\ ',
' ( {E} {E}) ',
' ( ω ) ',
' (")_(") ',
],
],
[dragon]: [
[
' ',
' /^\\ /^\\ ',
' < {E} {E} > ',
' ( ~~ ) ',
' `-vvvv-´ ',
],
[
' ',
' /^\\ /^\\ ',
' < {E} {E} > ',
' ( ) ',
' `-vvvv-´ ',
],
[
' ~ ~ ',
' /^\\ /^\\ ',
' < {E} {E} > ',
' ( ~~ ) ',
' `-vvvv-´ ',
],
],
[octopus]: [
[
' ',
' .----. ',
' ( {E} {E} ) ',
' (______) ',
' /\\/\\/\\/\\ ',
],
[
' ',
' .----. ',
' ( {E} {E} ) ',
' (______) ',
' \\/\\/\\/\\/ ',
],
[
' o ',
' .----. ',
' ( {E} {E} ) ',
' (______) ',
' /\\/\\/\\/\\ ',
],
],
[owl]: [
[
' ',
' /\\ /\\ ',
' (({E})({E})) ',
' ( >< ) ',
' `----´ ',
],
[
' ',
' /\\ /\\ ',
' (({E})({E})) ',
' ( >< ) ',
' .----. ',
],
[
' ',
' /\\ /\\ ',
' (({E})(-)) ',
' ( >< ) ',
' `----´ ',
],
],
[penguin]: [
[
' ',
' .---. ',
' ({E}>{E}) ',
' /( )\\ ',
' `---´ ',
],
[
' ',
' .---. ',
' ({E}>{E}) ',
' |( )| ',
' `---´ ',
],
[
' .---. ',
' ({E}>{E}) ',
' /( )\\ ',
' `---´ ',
' ~ ~ ',
],
],
[turtle]: [
[
' ',
' _,--._ ',
' ( {E} {E} ) ',
' /[______]\\ ',
' `` `` ',
],
[
' ',
' _,--._ ',
' ( {E} {E} ) ',
' /[______]\\ ',
' `` `` ',
],
[
' ',
' _,--._ ',
' ( {E} {E} ) ',
' /[======]\\ ',
' `` `` ',
],
],
[snail]: [
[
' ',
' {E} .--. ',
' \\ ( @ ) ',
' \\_`--´ ',
' ~~~~~~~ ',
],
[
' ',
' {E} .--. ',
' | ( @ ) ',
' \\_`--´ ',
' ~~~~~~~ ',
],
[
' ',
' {E} .--. ',
' \\ ( @ ) ',
' \\_`--´ ',
' ~~~~~~ ',
],
],
[ghost]: [
[
' ',
' .----. ',
' / {E} {E} \\ ',
' | | ',
' ~`~``~`~ ',
],
[
' ',
' .----. ',
' / {E} {E} \\ ',
' | | ',
' `~`~~`~` ',
],
[
' ~ ~ ',
' .----. ',
' / {E} {E} \\ ',
' | | ',
' ~~`~~`~~ ',
],
],
[axolotl]: [
[
' ',
'}~(______)~{',
'}~({E} .. {E})~{',
' ( .--. ) ',
' (_/ \\_) ',
],
[
' ',
'~}(______){~',
'~}({E} .. {E}){~',
' ( .--. ) ',
' (_/ \\_) ',
],
[
' ',
'}~(______)~{',
'}~({E} .. {E})~{',
' ( -- ) ',
' ~_/ \\_~ ',
],
],
[capybara]: [
[
' ',
' n______n ',
' ( {E} {E} ) ',
' ( oo ) ',
' `------´ ',
],
[
' ',
' n______n ',
' ( {E} {E} ) ',
' ( Oo ) ',
' `------´ ',
],
[
' ~ ~ ',
' u______n ',
' ( {E} {E} ) ',
' ( oo ) ',
' `------´ ',
],
],
[cactus]: [
[
' ',
' n ____ n ',
' | |{E} {E}| | ',
' |_| |_| ',
' | | ',
],
[
' ',
' ____ ',
' n |{E} {E}| n ',
' |_| |_| ',
' | | ',
],
[
' n n ',
' | ____ | ',
' | |{E} {E}| | ',
' |_| |_| ',
' | | ',
],
],
[robot]: [
[
' ',
' .[||]. ',
' [ {E} {E} ] ',
' [ ==== ] ',
' `------´ ',
],
[
' ',
' .[||]. ',
' [ {E} {E} ] ',
' [ -==- ] ',
' `------´ ',
],
[
' * ',
' .[||]. ',
' [ {E} {E} ] ',
' [ ==== ] ',
' `------´ ',
],
],
[rabbit]: [
[
' ',
' (\\__/) ',
' ( {E} {E} ) ',
' =( .. )= ',
' (")__(") ',
],
[
' ',
' (|__/) ',
' ( {E} {E} ) ',
' =( .. )= ',
' (")__(") ',
],
[
' ',
' (\\__/) ',
' ( {E} {E} ) ',
' =( . . )= ',
' (")__(") ',
],
],
[mushroom]: [
[
' ',
' .-o-OO-o-. ',
'(__________)',
' |{E} {E}| ',
' |____| ',
],
[
' ',
' .-O-oo-O-. ',
'(__________)',
' |{E} {E}| ',
' |____| ',
],
[
' . o . ',
' .-o-OO-o-. ',
'(__________)',
' |{E} {E}| ',
' |____| ',
],
],
[chonk]: [
[
' ',
' /\\ /\\ ',
' ( {E} {E} ) ',
' ( .. ) ',
' `------´ ',
],
[
' ',
' /\\ /| ',
' ( {E} {E} ) ',
' ( .. ) ',
' `------´ ',
],
[
' ',
' /\\ /\\ ',
' ( {E} {E} ) ',
' ( .. ) ',
' `------´~ ',
],
],
}
const HAT_LINES: Record<Hat, string> = {
none: '',
crown: ' \\^^^/ ',
tophat: ' [___] ',
propeller: ' -+- ',
halo: ' ( ) ',
wizard: ' /^\\ ',
beanie: ' (___) ',
tinyduck: ' ,> ',
}
export function renderSprite(bones: CompanionBones, frame = 0): string[] {
const frames = BODIES[bones.species]
const body = frames[frame % frames.length]!.map(line =>
line.replaceAll('{E}', bones.eye),
)
const lines = [...body]
// Only replace with hat if line 0 is empty (some fidget frames use it for smoke etc)
// 仅在第 0 行为空时替换帽子(某些 fidget 帧用它来表示烟等)
if (bones.hat !== 'none' && !lines[0]!.trim()) {
lines[0] = HAT_LINES[bones.hat]
}
// Drop blank hat slot — wastes a row in the Card and ambient sprite when
// there's no hat and the frame isn't using it for smoke/antenna/etc.
// Only safe when ALL frames have blank line 0; otherwise heights oscillate.
// 删除空帽子槽位 — 当没有帽子且帧没有用它来表示烟/天线等时,
// 在 Card 和 ambient sprite 中浪费一行。
// 只有当所有帧的第 0 行都为空时才安全;否则高度会振荡。
if (!lines[0]!.trim() && frames.every(f => !f[0]!.trim())) lines.shift()
return lines
}
export function spriteFrameCount(species: Species): number {
return BODIES[species].length
}
export function renderFace(bones: CompanionBones): string {
const eye: Eye = bones.eye
switch (bones.species) {
case duck:
case goose:
return `(${eye}>`
case blob:
return `(${eye}${eye})`
case cat:
return `=${eye}ω${eye}=`
case dragon:
return `<${eye}~${eye}>`
case octopus:
return `~(${eye}${eye})~`
case owl:
return `(${eye})(${eye})`
case penguin:
return `(${eye}>)`
case turtle:
return `[${eye}_${eye}]`
case snail:
return `${eye}(@)`
case ghost:
return `/${eye}${eye}\\`
case axolotl:
return `}${eye}.${eye}{`
case capybara:
return `(${eye}oo${eye})`
case cactus:
return `|${eye} ${eye}|`
case robot:
return `[${eye}${eye}]`
case rabbit:
return `(${eye}..${eye})`
case mushroom:
return `|${eye} ${eye}|`
case chonk:
return `(${eye}.${eye})`
}
}

View File

@@ -0,0 +1,157 @@
export const RARITIES = [
'common',
'uncommon',
'rare',
'epic',
'legendary',
] as const
export type Rarity = (typeof RARITIES)[number]
// One species name collides with a model-codename canary in excluded-strings.txt.
// The check greps build output (not source), so runtime-constructing the value keeps
// the literal out of the bundle while the check stays armed for the actual codename.
// All species encoded uniformly; `as` casts are type-position only (erased pre-bundle).
// 一个 species 名称与 excluded-strings.txt 中的 model-codename canary 冲突。
// 检查 grep 构建输出(而非源),因此在运行时构造值会将
// 字面量保持在 bundle 之外,同时检查保持对实际 codename 的警戒。
// 所有 species 统一编码;`as` 转换仅在类型位置bundle 前被擦除)。
const c = String.fromCharCode
// biome-ignore format: keep the species list compact
export const duck = c(0x64,0x75,0x63,0x6b) as 'duck'
export const goose = c(0x67, 0x6f, 0x6f, 0x73, 0x65) as 'goose'
export const blob = c(0x62, 0x6c, 0x6f, 0x62) as 'blob'
export const cat = c(0x63, 0x61, 0x74) as 'cat'
export const dragon = c(0x64, 0x72, 0x61, 0x67, 0x6f, 0x6e) as 'dragon'
export const octopus = c(0x6f, 0x63, 0x74, 0x6f, 0x70, 0x75, 0x73) as 'octopus'
export const owl = c(0x6f, 0x77, 0x6c) as 'owl'
export const penguin = c(0x70, 0x65, 0x6e, 0x67, 0x75, 0x69, 0x6e) as 'penguin'
export const turtle = c(0x74, 0x75, 0x72, 0x74, 0x6c, 0x65) as 'turtle'
export const snail = c(0x73, 0x6e, 0x61, 0x69, 0x6c) as 'snail'
export const ghost = c(0x67, 0x68, 0x6f, 0x73, 0x74) as 'ghost'
export const axolotl = c(0x61, 0x78, 0x6f, 0x6c, 0x6f, 0x74, 0x6c) as 'axolotl'
export const capybara = c(
0x63,
0x61,
0x70,
0x79,
0x62,
0x61,
0x72,
0x61,
) as 'capybara'
export const cactus = c(0x63, 0x61, 0x63, 0x74, 0x75, 0x73) as 'cactus'
export const robot = c(0x72, 0x6f, 0x62, 0x6f, 0x74) as 'robot'
export const rabbit = c(0x72, 0x61, 0x62, 0x62, 0x69, 0x74) as 'rabbit'
export const mushroom = c(
0x6d,
0x75,
0x73,
0x68,
0x72,
0x6f,
0x6f,
0x6d,
) as 'mushroom'
export const chonk = c(0x63, 0x68, 0x6f, 0x6e, 0x6b) as 'chonk'
export const SPECIES = [
duck,
goose,
blob,
cat,
dragon,
octopus,
owl,
penguin,
turtle,
snail,
ghost,
axolotl,
capybara,
cactus,
robot,
rabbit,
mushroom,
chonk,
] as const
export type Species = (typeof SPECIES)[number] // biome-ignore format: keep compact
export const EYES = ['·', '✦', '×', '◉', '@', '°'] as const
export type Eye = (typeof EYES)[number]
export const HATS = [
'none',
'crown',
'tophat',
'propeller',
'halo',
'wizard',
'beanie',
'tinyduck',
] as const
export type Hat = (typeof HATS)[number]
export const STAT_NAMES = [
'DEBUGGING',
'PATIENCE',
'CHAOS',
'WISDOM',
'SNARK',
] as const
export type StatName = (typeof STAT_NAMES)[number]
// Deterministic parts — derived from hash(userId)
// 确定性部分 — 来自 hash(userId) 派生
export type CompanionBones = {
rarity: Rarity
species: Species
eye: Eye
hat: Hat
shiny: boolean
stats: Record<StatName, number>
}
// Model-generated soul — stored in config after first hatch
// 模型生成的 soul — 首次孵化后存储在配置中
export type CompanionSoul = {
name: string
personality: string
}
export type Companion = CompanionBones &
CompanionSoul & {
hatchedAt: number
}
// What actually persists in config. Bones are regenerated from hash(userId)
// on every read so species renames don't break stored companions and users
// can't edit their way to a legendary.
// 实际持久化到配置的内容。每次读取时从 hash(userId) 重新生成 Bones
// 以便 species 重命名不会破坏存储的 companions用户
// 无法通过编辑获得 legendary。
export type StoredCompanion = CompanionSoul & { hatchedAt: number }
export const RARITY_WEIGHTS = {
common: 60,
uncommon: 25,
rare: 10,
epic: 4,
legendary: 1,
} as const satisfies Record<Rarity, number>
export const RARITY_STARS = {
common: '★',
uncommon: '★★',
rare: '★★★',
epic: '★★★★',
legendary: '★★★★★',
} as const satisfies Record<Rarity, string>
export const RARITY_COLORS = {
common: 'inactive',
uncommon: 'success',
rare: 'permission',
epic: 'autoAccept',
legendary: 'warning',
} as const satisfies Record<Rarity, keyof import('../utils/theme.js').Theme>

View File

@@ -0,0 +1,39 @@
/**
* CLI exit helpers for subcommand handlers.
*
* Consolidates the 4-5 line "print + lint-suppress + exit" block that was
* copy-pasted ~60 times across `claude mcp *` / `claude plugin *` handlers.
* The `: never` return type lets TypeScript narrow control flow at call sites
* without a trailing `return`.
* CLI 退出辅助函数,用于子命令处理程序。
*
* 整合了跨 `claude mcp *` / `claude plugin *` 处理程序复制粘贴约 60 次的
* 4-5 行 "print + lint-suppress + exit" 块。
* `: never` 返回类型让 TypeScript 在调用站点缩小控制流,
* 无需尾随 `return`。
*/
/* eslint-disable custom-rules/no-process-exit -- centralized CLI exit point */
// `return undefined as never` (not a post-exit throw) — tests spy on
// process.exit and let it return. Call sites write `return cliError(...)`
// where subsequent code would dereference narrowed-away values under mock.
// cliError uses console.error (tests spy on console.error); cliOk uses
// process.stdout.write (tests spy on process.stdout.write — Bun's console.log
// doesn't route through a spied process.stdout.write).
/** Write an error message to stderr (if given) and exit with code 1. */
/** 将错误消息写入 stderr如果有并以代码 1 退出。 */
export function cliError(msg?: string): never {
// biome-ignore lint/suspicious/noConsole: centralized CLI error output
if (msg) console.error(msg)
process.exit(1)
return undefined as never
}
/** Write a message to stdout (if given) and exit with code 0. */
/** 将消息写入 stdout如果有并以代码 0 退出。 */
export function cliOk(msg?: string): never {
if (msg) process.stdout.write(msg + '\n')
process.exit(0)
return undefined as never
}

View File

@@ -0,0 +1,51 @@
import { jsonStringify } from '../utils/slowOperations.js'
// JSON.stringify emits U+2028/U+2029 raw (valid per ECMA-404). When the
// output is a single NDJSON line, any receiver that uses JavaScript
// line-terminator semantics (ECMA-262 §11.3 — \n \r U+2028 U+2029) to
// split the stream will cut the JSON mid-string. ProcessTransport now
// silently skips non-JSON lines rather than crashing (gh-28405), but
// the truncated fragment is still lost — the message is silently dropped.
//
// The \uXXXX form is equivalent JSON (parses to the same string) but
// can never be mistaken for a line terminator by ANY receiver. This is
// what ES2019's "Subsume JSON" proposal and Node's util.inspect do.
//
// Single regex with alternation: the callback's one dispatch per match
// is cheaper than two full-string scans.
// JSON.stringify 发出原始 U+2028/U+2029按 ECMA-404 有效)。当
// 输出是单个 NDJSON 行时,任何使用 JavaScript
// 行终止符语义ECMA-262 §11.3 — \n \r U+2028 U+2029分割
// 流的接收者都会在 JSON 字符串中间切割。ProcessTransport 现在
// 静默跳过非 JSON 行而不是崩溃gh-28405
// 截断的片段仍然丢失 — 消息被静默丢弃。
//
// \uXXXX 形式是等效的 JSON解析为相同的字符串
// 任何接收者都不会将其误认为行终止符。这就是 ES2019 的
// "Subsume JSON"提案和 Node 的 util.inspect 所做的。
//
// 带交替的单个正则表达式:回调的每次匹配一次分派
// 比两次全字符串扫描更便宜。
const JS_LINE_TERMINATORS = /\u2028|\u2029/g
function escapeJsLineTerminators(json: string): string {
return json.replace(JS_LINE_TERMINATORS, c =>
c === '\u2028' ? '\\u2028' : '\\u2029',
)
}
/**
* JSON.stringify for one-message-per-line transports. Escapes U+2028
* LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR so the serialized output
* cannot be broken by a line-splitting receiver. Output is still valid
* JSON and parses to the same value.
*/
/**
* 用于每行一条消息的传输的 JSON.stringify。转义 U+2028
* LINE SEPARATOR 和 U+2029 PARAGRAPH SEPARATOR以便序列化输出
* 不会被行分割接收者破坏。输出仍然是有效的
* JSON 并解析为相同的值。
*/
export function ndjsonSafeStringify(value: unknown): string {
return escapeJsLineTerminators(jsonStringify(value))
}

View File

@@ -0,0 +1,857 @@
import { feature } from 'bun:bundle'
import type {
ElicitResult,
JSONRPCMessage,
} from '@modelcontextprotocol/sdk/types.js'
import { randomUUID } from 'crypto'
import type { AssistantMessage } from 'src//types/message.js'
import type {
HookInput,
HookJSONOutput,
PermissionUpdate,
SDKMessage,
SDKUserMessage,
} from 'src/entrypoints/agentSdkTypes.js'
import { SDKControlElicitationResponseSchema } from 'src/entrypoints/sdk/controlSchemas.js'
import type {
SDKControlRequest,
SDKControlResponse,
StdinMessage,
StdoutMessage,
} from 'src/entrypoints/sdk/controlTypes.js'
import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
import type { Tool, ToolUseContext } from 'src/Tool.js'
import { type HookCallback, hookJSONOutputSchema } from 'src/types/hooks.js'
import { logForDebugging } from 'src/utils/debug.js'
import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js'
import { AbortError } from 'src/utils/errors.js'
import {
type Output as PermissionToolOutput,
permissionPromptToolResultToPermissionDecision,
outputSchema as permissionToolOutputSchema,
} from 'src/utils/permissions/PermissionPromptToolResultSchema.js'
import type {
PermissionDecision,
PermissionDecisionReason,
} from 'src/utils/permissions/PermissionResult.js'
import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js'
import { writeToStdout } from 'src/utils/process.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { z } from 'zod/v4'
import { notifyCommandLifecycle } from '../utils/commandLifecycle.js'
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
import { executePermissionRequestHooks } from '../utils/hooks.js'
import {
applyPermissionUpdates,
persistPermissionUpdates,
} from '../utils/permissions/PermissionUpdate.js'
import {
notifySessionStateChanged,
type RequiresActionDetails,
type SessionExternalMetadata,
} from '../utils/sessionState.js'
import { jsonParse } from '../utils/slowOperations.js'
import { Stream } from '../utils/stream.js'
import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
/**
* 通过 can_use_tool control_request 协议转发沙箱网络权限
* 请求时使用的合成工具名称。SDK 主机
* 将此视为正常的工具权限提示。
*/
export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess'
function serializeDecisionReason(
reason: PermissionDecisionReason | undefined,
): string | undefined {
if (!reason) {
return undefined
}
if (
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
reason.type === 'classifier'
) {
return reason.reason
}
switch (reason.type) {
case 'rule':
case 'mode':
case 'subcommandResults':
case 'permissionPromptTool':
return undefined
case 'hook':
case 'asyncAgent':
case 'sandboxOverride':
case 'workingDir':
case 'safetyCheck':
case 'other':
return reason.reason
}
}
function buildRequiresActionDetails(
tool: Tool,
input: Record<string, unknown>,
toolUseID: string,
requestId: string,
): RequiresActionDetails {
// 每个工具的摘要方法可能在格式错误的输入上抛出;权限
// 处理不能因为错误的描述而中断。
let description: string
try {
description =
tool.getActivityDescription?.(input) ??
tool.getToolUseSummary?.(input) ??
tool.userFacingName(input)
} catch {
description = tool.name
}
return {
tool_name: tool.name,
action_description: description,
tool_use_id: toolUseID,
request_id: requestId,
input,
}
}
type PendingRequest<T> = {
resolve: (result: T) => void
reject: (error: unknown) => void
schema?: z.Schema
request: SDKControlRequest
}
/**
* 提供从 stdio 读取和写入 SDK 消息的结构化方式,
* 捕获 SDK 协议。
*/
// 要跟踪的已解析 tool_use ID 的最大数量。超过后,
// 最旧的条目被驱逐。这在非常长的会话中绑定内存,
// 同时保留足够的历史以捕获重复的 control_response 投递。
const MAX_RESOLVED_TOOL_USE_IDS = 1000
export class StructuredIO {
readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage>
private readonly pendingRequests = new Map<string, PendingRequest<unknown>>()
// 在 worker 启动时读回的 CCR external_metadata
// 当传输不恢复时为 null。由 RemoteIO 分配。
restoredWorkerState: Promise<SessionExternalMetadata | null> =
Promise.resolve(null)
private inputClosed = false
private unexpectedResponseCallback?: (
response: SDKControlResponse,
) => Promise<void>
// 跟踪已通过正常权限流程(或被 hook 中止)解决的 tool_use ID。
// 当重复的 control_response 在原始处理程序已经处理之后到达时,
// 此 Set 防止孤立处理程序重新处理它 — 这会将重复的 assistant
// 消息推入 mutableMessages 并导致 API 400 "tool_use ids must be unique"
// 错误。
private readonly resolvedToolUseIds = new Set<string>()
private prependedLines: string[] = []
private onControlRequestSent?: (request: SDKControlRequest) => void
private onControlRequestResolved?: (requestId: string) => void
// sendRequest() 和 print.ts 都排队到这里drain 循环是
// 唯一的写入者。防止 control_request 超过排队的 stream_events。
readonly outbound = new Stream<StdoutMessage>()
constructor(
private readonly input: AsyncIterable<string>,
private readonly replayUserMessages?: boolean,
) {
this.input = input
this.structuredInput = this.read()
}
/**
* 记录已解决的 tool_use ID以便忽略相同工具的晚期/重复 control_response
* 消息。
*/
private trackResolvedToolUseId(request: SDKControlRequest): void {
if (request.request.subtype === 'can_use_tool') {
this.resolvedToolUseIds.add(request.request.tool_use_id)
if (this.resolvedToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) {
// 驱逐最旧的条目Set 按插入顺序迭代)
const first = this.resolvedToolUseIds.values().next().value
if (first !== undefined) {
this.resolvedToolUseIds.delete(first)
}
}
}
}
/** 刷新待处理的内部事件。非远程 IO 的空操作。由 RemoteIO 重写。 */
flushInternalEvents(): Promise<void> {
return Promise.resolve()
}
/** 内部事件队列深度。由 RemoteIO 重写;否则为 0。 */
get internalEventsPending(): number {
return 0
}
/**
* 排队用户轮次以在此.input 的下一条消息之前产生。
* 在迭代开始之前和中间都有效 — read() 在
* 每个产生的消息之间重新检查 prependedLines。
*/
prependUserMessage(content: string): void {
this.prependedLines.push(
jsonStringify({
type: 'user',
session_id: '',
message: { role: 'user', content },
parent_tool_use_id: null,
} satisfies SDKUserMessage) + '\n',
)
}
private async *read() {
let content = ''
// 在 for-await 之前调用一次(空 this.input 否则完全跳过
// 循环体然后每个块调用一次。prependedLines 重新检查在 while 内,
// 以便在同一块中两条消息之间推送的 prepend 仍然首先落地。
const splitAndProcess = async function* (this: StructuredIO) {
for (;;) {
if (this.prependedLines.length > 0) {
content = this.prependedLines.join('') + content
this.prependedLines = []
}
const newline = content.indexOf('\n')
if (newline === -1) break
const line = content.slice(0, newline)
content = content.slice(newline + 1)
const message = await this.processLine(line)
if (message) {
logForDiagnosticsNoPII('info', 'cli_stdin_message_parsed', {
type: message.type,
})
yield message
}
}
}.bind(this)
yield* splitAndProcess()
for await (const block of this.input) {
content += block
yield* splitAndProcess()
}
if (content) {
const message = await this.processLine(content)
if (message) {
yield message
}
}
this.inputClosed = true
for (const request of this.pendingRequests.values()) {
// 如果输入流在收到响应之前关闭,则拒绝所有待处理请求
request.reject(
new Error('Tool permission stream closed before response received'),
)
}
}
getPendingPermissionRequests() {
return Array.from(this.pendingRequests.values())
.map(entry => entry.request)
.filter(pr => pr.request.subtype === 'can_use_tool')
}
setUnexpectedResponseCallback(
callback: (response: SDKControlResponse) => Promise<void>,
): void {
this.unexpectedResponseCallback = callback
}
/**
* 注入 control_response 消息以解决待处理的权限请求。
* 由 bridge 用来将 claude.ai 的权限响应反馈到
* SDK 权限流程。
*
* 还向 SDK 消费者发送 control_cancel_request以通过 signal 中止其 canUseTool
* 回调 — 否则回调会挂起。
*/
injectControlResponse(response: SDKControlResponse): void {
const requestId = response.response?.request_id
if (!requestId) return
const request = this.pendingRequests.get(requestId)
if (!request) return
this.trackResolvedToolUseId(request.request)
this.pendingRequests.delete(requestId)
// 取消 SDK 消费者的 canUseTool 回调 — bridge 赢了。
void this.write({
type: 'control_cancel_request',
request_id: requestId,
})
if (response.response.subtype === 'error') {
request.reject(new Error(response.response.error))
} else {
const result = response.response.response
if (request.schema) {
try {
request.resolve(request.schema.parse(result))
} catch (error) {
request.reject(error)
}
} else {
request.resolve({})
}
}
}
/**
* 注册一个回调,该回调在写入 stdout 的每个 can_use_tool control_request
* 时调用。由 bridge 用来将权限
* 请求转发到 claude.ai。
*/
setOnControlRequestSent(
callback: ((request: SDKControlRequest) => void) | undefined,
): void {
this.onControlRequestSent = callback
}
/**
* 注册一个回调,该回调在 can_use_tool control_response 从
* SDK 消费者(通过 stdin到达时调用。由 bridge 用来在 SDK 消费者赢得竞速时
* 取消 claude.ai 上的过时权限提示。
*/
setOnControlRequestResolved(
callback: ((requestId: string) => void) | undefined,
): void {
this.onControlRequestResolved = callback
}
private async processLine(
line: string,
): Promise<StdinMessage | SDKMessage | undefined> {
// 跳过空行(例如来自管道 stdin 中的双换行)
if (!line) {
return undefined
}
try {
const message = normalizeControlMessageKeys(jsonParse(line)) as
| StdinMessage
| SDKMessage
if (message.type === 'keep_alive') {
// 静默忽略 keep-alive 消息
return undefined
}
if (message.type === 'update_environment_variables') {
// 直接将环境变量更新应用到 process.env。
// 由 bridge session runner 用于 auth token 刷新
//CLAUDE_CODE_SESSION_ACCESS_TOKEN必须可读
// 由 REPL 进程本身,而不仅仅是子 Bash 命令。
const keys = Object.keys(message.variables)
for (const [key, value] of Object.entries(message.variables)) {
process.env[key] = value
}
logForDebugging(
`[structuredIO] applied update_environment_variables: ${keys.join(', ')}`,
)
return undefined
}
if (message.type === 'control_response') {
// 为每个 control_response 关闭生命周期,包括重复
// 和孤立 — 孤立者不会 yield 到 print.ts 的主循环,所以这是
// 唯一看到它们的路径。uuid 是服务器注入到
// payload 中的。
const uuid =
'uuid' in message && typeof message.uuid === 'string'
? message.uuid
: undefined
if (uuid) {
notifyCommandLifecycle(uuid, 'completed')
}
const request = this.pendingRequests.get(message.response.request_id)
if (!request) {
// 检查此 tool_use 是否已通过正常权限流程解决。
// 重复的 control_response 投递(例如来自
// WebSocket 重连)在原始处理程序处理之后到达,
// 重新处理它们会将重复的 assistant 消息推入
// 对话,导致 API 400 错误。
const responsePayload =
message.response.subtype === 'success'
? message.response.response
: undefined
const toolUseID = responsePayload?.toolUseID
if (
typeof toolUseID === 'string' &&
this.resolvedToolUseIds.has(toolUseID)
) {
logForDebugging(
`Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`,
)
return undefined
}
if (this.unexpectedResponseCallback) {
await this.unexpectedResponseCallback(message)
}
return undefined // 忽略我们不知道的请求的响应
}
this.trackResolvedToolUseId(request.request)
this.pendingRequests.delete(message.response.request_id)
// 当 SDK 消费者解决 can_use_tool 请求时通知 bridge
// 以便它可以取消 claude.ai 上的过时权限提示。
if (
request.request.request.subtype === 'can_use_tool' &&
this.onControlRequestResolved
) {
this.onControlRequestResolved(message.response.request_id)
}
if (message.response.subtype === 'error') {
request.reject(new Error(message.response.error))
return undefined
}
const result = message.response.response
if (request.schema) {
try {
request.resolve(request.schema.parse(result))
} catch (error) {
request.reject(error)
}
} else {
request.resolve({})
}
// 启用重放时传播控制响应
if (this.replayUserMessages) {
return message
}
return undefined
}
if (
message.type !== 'user' &&
message.type !== 'control_request' &&
message.type !== 'assistant' &&
message.type !== 'system'
) {
logForDebugging(`Ignoring unknown message type: ${message.type}`, {
level: 'warn',
})
return undefined
}
if (message.type === 'control_request') {
if (!message.request) {
exitWithMessage(`Error: Missing request on control_request`)
}
return message
}
if (message.type === 'assistant' || message.type === 'system') {
return message
}
if (message.message.role !== 'user') {
exitWithMessage(
`Error: Expected message role 'user', got '${message.message.role}'`,
)
}
return message
} catch (error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error parsing streaming input line: ${line}: ${error}`)
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(1)
}
}
async write(message: StdoutMessage): Promise<void> {
writeToStdout(ndjsonSafeStringify(message) + '\n')
}
private async sendRequest<Response>(
request: SDKControlRequest['request'],
schema: z.Schema,
signal?: AbortSignal,
requestId: string = randomUUID(),
): Promise<Response> {
const message: SDKControlRequest = {
type: 'control_request',
request_id: requestId,
request,
}
if (this.inputClosed) {
throw new Error('Stream closed')
}
if (signal?.aborted) {
throw new Error('Request aborted')
}
this.outbound.enqueue(message)
if (request.subtype === 'can_use_tool' && this.onControlRequestSent) {
this.onControlRequestSent(message)
}
const aborted = () => {
this.outbound.enqueue({
type: 'control_cancel_request',
request_id: requestId,
})
// 立即拒绝未完成的 promise
// 而不等待主机确认取消。
const request = this.pendingRequests.get(requestId)
if (request) {
// 在拒绝之前将 tool_use ID 跟踪为已解决,以便
// 主机的晚期响应被孤立处理程序忽略。
this.trackResolvedToolUseId(request.request)
request.reject(new AbortError())
}
}
if (signal) {
signal.addEventListener('abort', aborted, {
once: true,
})
}
try {
return await new Promise<Response>((resolve, reject) => {
this.pendingRequests.set(requestId, {
request: {
type: 'control_request',
request_id: requestId,
request,
},
resolve: result => {
resolve(result as Response)
},
reject,
schema,
})
})
} finally {
if (signal) {
signal.removeEventListener('abort', aborted)
}
this.pendingRequests.delete(requestId)
}
}
createCanUseTool(
onPermissionPrompt?: (details: RequiresActionDetails) => void,
): CanUseToolFn {
return async (
tool: Tool,
input: { [key: string]: unknown },
toolUseContext: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
forceDecision?: PermissionDecision,
): Promise<PermissionDecision> => {
const mainPermissionResult =
forceDecision ??
(await hasPermissionsToUseTool(
tool,
input,
toolUseContext,
assistantMessage,
toolUseID,
))
// 如果工具被允许或拒绝,返回结果
if (
mainPermissionResult.behavior === 'allow' ||
mainPermissionResult.behavior === 'deny'
) {
return mainPermissionResult
}
// 与 SDK 权限并行运行 PermissionRequest hooks。
// 在终端 CLI 中hooks 与交互式
// 提示竞速,以便例如带有 --delay 20 的 hook 不会阻塞 UI。
// 我们在这里需要相同的行为SDK 主机VS Code 等)立即显示
// 其权限对话框,而 hooks 在后台运行。
// 先解决者获胜;失败者被取消/忽略。
// AbortController 用于在 hook 首先决定时取消 SDK 请求
const hookAbortController = new AbortController()
const parentSignal = toolUseContext.abortController.signal
// 将父中止转发到我们的本地控制器
const onParentAbort = () => hookAbortController.abort()
parentSignal.addEventListener('abort', onParentAbort, { once: true })
try {
// 启动 hook 评估(后台运行)
const hookPromise = executePermissionRequestHooksForSDK(
tool.name,
toolUseID,
input,
toolUseContext,
mainPermissionResult.suggestions,
).then(decision => ({ source: 'hook' as const, decision }))
// 立即启动 SDK 权限提示(不等待 hooks
const requestId = randomUUID()
onPermissionPrompt?.(
buildRequiresActionDetails(tool, input, toolUseID, requestId),
)
const sdkPromise = this.sendRequest<PermissionToolOutput>(
{
subtype: 'can_use_tool',
tool_name: tool.name,
input,
permission_suggestions: mainPermissionResult.suggestions,
blocked_path: mainPermissionResult.blockedPath,
decision_reason: serializeDecisionReason(
mainPermissionResult.decisionReason,
),
tool_use_id: toolUseID,
agent_id: toolUseContext.agentId,
},
permissionToolOutputSchema(),
hookAbortController.signal,
requestId,
).then(result => ({ source: 'sdk' as const, result }))
// 竞速hook 完成 vs SDK 提示响应。
// hook promise 总是解决(从不拒绝),如果没有 hook 做出决定则返回
// undefined。
const winner = await Promise.race([hookPromise, sdkPromise])
if (winner.source === 'hook') {
if (winner.decision) {
// Hook 决定了 — 中止待处理的 SDK 请求。
// 抑制来自 sdkPromise 的预期 AbortError 拒绝。
sdkPromise.catch(() => {})
hookAbortController.abort()
return winner.decision
}
// Hook 通过了(无决定)— 等待 SDK 提示
const sdkResult = await sdkPromise
return permissionPromptToolResultToPermissionDecision(
sdkResult.result,
tool,
input,
toolUseContext,
)
}
// SDK 提示先响应 — 使用其结果hook 在
// 后台运行但其结果将被忽略)
return permissionPromptToolResultToPermissionDecision(
winner.result,
tool,
input,
toolUseContext,
)
} catch (error) {
return permissionPromptToolResultToPermissionDecision(
{
behavior: 'deny',
message: `Tool permission request failed: ${error}`,
toolUseID,
},
tool,
input,
toolUseContext,
)
} finally {
// 仅当没有其他权限提示
// 挂起时转换回 'running'(并发工具执行可以有多个 in-flight
if (this.getPendingPermissionRequests().length === 0) {
notifySessionStateChanged('running')
}
parentSignal.removeEventListener('abort', onParentAbort)
}
}
}
createHookCallback(callbackId: string, timeout?: number): HookCallback {
return {
type: 'callback',
timeout,
callback: async (
input: HookInput,
toolUseID: string | null,
abort: AbortSignal | undefined,
): Promise<HookJSONOutput> => {
try {
const result = await this.sendRequest<HookJSONOutput>(
{
subtype: 'hook_callback',
callback_id: callbackId,
input,
tool_use_id: toolUseID || undefined,
},
hookJSONOutputSchema(),
abort,
)
return result
} catch (error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error in hook callback ${callbackId}:`, error)
return {}
}
},
}
}
/**
* 向 SDK 消费者发送 elicitation 请求并返回响应。
*/
async handleElicitation(
serverName: string,
message: string,
requestedSchema?: Record<string, unknown>,
signal?: AbortSignal,
mode?: 'form' | 'url',
url?: string,
elicitationId?: string,
): Promise<ElicitResult> {
try {
const result = await this.sendRequest<ElicitResult>(
{
subtype: 'elicitation',
mcp_server_name: serverName,
message,
mode,
url,
elicitation_id: elicitationId,
requested_schema: requestedSchema,
},
SDKControlElicitationResponseSchema(),
signal,
)
return result
} catch {
return { action: 'cancel' as const }
}
}
/**
* 创建一个 SandboxAskCallback将沙箱网络权限
* 请求转发到 SDK 主机作为 can_use_tool control_requests。
*
* 这利用现有的 can_use_tool 协议与合成
* 工具名称,以便 SDK 主机VS Code、CCR 等)可以提示用户
* 网络访问而无需新的协议子类型。
*/
createSandboxAskCallback(): (hostPattern: {
host: string
port?: number
}) => Promise<boolean> {
return async (hostPattern): Promise<boolean> => {
try {
const result = await this.sendRequest<PermissionToolOutput>(
{
subtype: 'can_use_tool',
tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME,
input: { host: hostPattern.host },
tool_use_id: randomUUID(),
description: `Allow network connection to ${hostPattern.host}?`,
},
permissionToolOutputSchema(),
)
return result.behavior === 'allow'
} catch {
// 如果请求失败(流关闭、中止等),拒绝连接
return false
}
}
}
/**
* 向 SDK 服务器发送 MCP 消息并等待响应
*/
async sendMcpMessage(
serverName: string,
message: JSONRPCMessage,
): Promise<JSONRPCMessage> {
const response = await this.sendRequest<{ mcp_response: JSONRPCMessage }>(
{
subtype: 'mcp_message',
server_name: serverName,
message,
},
z.object({
mcp_response: z.any() as z.Schema<JSONRPCMessage>,
}),
)
return response.mcp_response
}
}
function exitWithMessage(message: string): never {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(message)
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(1)
}
/**
* 执行 PermissionRequest hooks 并在做出决定时返回决定。
* 如果没有 hook 做出决定则返回 undefined。
*/
async function executePermissionRequestHooksForSDK(
toolName: string,
toolUseID: string,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
suggestions: PermissionUpdate[] | undefined,
): Promise<PermissionDecision | undefined> {
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
// 直接迭代生成器而不是使用 `all`
const hookGenerator = executePermissionRequestHooks(
toolName,
toolUseID,
input,
toolUseContext,
permissionMode,
suggestions,
toolUseContext.abortController.signal,
)
for await (const hookResult of hookGenerator) {
if (
hookResult.permissionRequestResult &&
(hookResult.permissionRequestResult.behavior === 'allow' ||
hookResult.permissionRequestResult.behavior === 'deny')
) {
const decision = hookResult.permissionRequestResult
if (decision.behavior === 'allow') {
const finalInput = decision.updatedInput || input
// 如果 hook 提供则应用权限更新("始终允许"
const permissionUpdates = decision.updatedPermissions ?? []
if (permissionUpdates.length > 0) {
persistPermissionUpdates(permissionUpdates)
const currentAppState = toolUseContext.getAppState()
const updatedContext = applyPermissionUpdates(
currentAppState.toolPermissionContext,
permissionUpdates,
)
// 通过 setAppState 更新权限上下文
toolUseContext.setAppState(prev => {
if (prev.toolPermissionContext === updatedContext) return prev
return { ...prev, toolPermissionContext: updatedContext }
})
}
return {
behavior: 'allow',
updatedInput: finalInput,
userModified: false,
decisionReason: {
type: 'hook',
hookName: 'PermissionRequest',
},
}
} else {
// Hook 拒绝了权限
return {
behavior: 'deny',
message:
decision.message || 'Permission denied by PermissionRequest hook',
decisionReason: {
type: 'hook',
hookName: 'PermissionRequest',
},
}
}
}
}
return undefined
}

View File

@@ -0,0 +1,253 @@
import type { StdoutMessage } from 'src/entrypoints/sdk/controlTypes.js'
import { PassThrough } from 'stream'
import { URL } from 'url'
import { getSessionId } from '../bootstrap/state.js'
import { getPollIntervalConfig } from '../bridge/pollConfig.js'
import { registerCleanup } from '../utils/cleanupRegistry.js'
import { setCommandLifecycleListener } from '../utils/commandLifecycle.js'
import { isDebugMode, logForDebugging } from '../utils/debug.js'
import { logForDiagnosticsNoPII } from '../utils/diagLogs.js'
import { isEnvTruthy } from '../utils/envUtils.js'
import { errorMessage } from '../utils/errors.js'
import { gracefulShutdown } from '../utils/gracefulShutdown.js'
import { logError } from '../utils/log.js'
import { writeToStdout } from '../utils/process.js'
import { getSessionIngressAuthToken } from '../utils/sessionIngressAuth.js'
import {
setSessionMetadataChangedListener,
setSessionStateChangedListener,
} from '../utils/sessionState.js'
import {
setInternalEventReader,
setInternalEventWriter,
} from '../utils/sessionStorage.js'
import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
import { StructuredIO } from './structuredIO.js'
import { CCRClient, CCRInitError } from './transports/ccrClient.js'
import { SSETransport } from './transports/SSETransport.js'
import type { Transport } from './transports/Transport.js'
import { getTransportForUrl } from './transports/transportUtils.js'
/**
* 用于 SDK 模式的双向流,带会话跟踪
* 支持 WebSocket 传输
*/
export class RemoteIO extends StructuredIO {
private url: URL
private transport: Transport
private inputStream: PassThrough
private readonly isBridge: boolean = false
private readonly isDebug: boolean = false
private ccrClient: CCRClient | null = null
private keepAliveTimer: ReturnType<typeof setInterval> | null = null
constructor(
streamUrl: string,
initialPrompt?: AsyncIterable<string>,
replayUserMessages?: boolean,
) {
const inputStream = new PassThrough({ encoding: 'utf8' })
super(inputStream, replayUserMessages)
this.inputStream = inputStream
this.url = new URL(streamUrl)
// 如果有会话 token准备带会话 token 的 headers
const headers: Record<string, string> = {}
const sessionToken = getSessionIngressAuthToken()
if (sessionToken) {
headers['Authorization'] = `Bearer ${sessionToken}`
} else {
logForDebugging('[remote-io] No session ingress token available', {
level: 'error',
})
}
// 如果有环境运行器版本则添加(由 Environment Manager 设置)
const erVersion = process.env.CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION
if (erVersion) {
headers['x-environment-runner-version'] = erVersion
}
// 提供一个动态重新读取会话 token 的回调。
// 当父进程刷新 token 时(通过 token 文件或 env var
// 传输可以在重新连接时拾取它。
const refreshHeaders = (): Record<string, string> => {
const h: Record<string, string> = {}
const freshToken = getSessionIngressAuthToken()
if (freshToken) {
h['Authorization'] = `Bearer ${freshToken}`
}
const freshErVersion = process.env.CLAUDE_CODE_ENVIRONMENT_RUNNER_VERSION
if (freshErVersion) {
h['x-environment-runner-version'] = freshErVersion
}
return h
}
// 根据 URL 协议获取适当的传输
this.transport = getTransportForUrl(
this.url,
headers,
getSessionId(),
refreshHeaders,
)
// 设置数据回调
this.isBridge = process.env.CLAUDE_CODE_ENVIRONMENT_KIND === 'bridge'
this.isDebug = isDebugMode()
this.transport.setOnData((data: string) => {
this.inputStream.write(data)
if (this.isBridge && this.isDebug) {
writeToStdout(data.endsWith('\n') ? data : data + '\n')
}
})
// 设置关闭回调以处理连接失败
this.transport.setOnClose(() => {
// 结束输入流以触发 graceful shutdown
this.inputStream.end()
})
// 初始化 CCR v2 客户端心跳、epoch、状态报告、事件写入
// CCRClient 构造函数同步连接 SSE received-ack 处理程序,
// 所以 new CCRClient() 必须 在 transport.connect() 之前运行 —
// 否则早期 SSE 帧会遇到未连接的 onEventCallback
// 它们的 'received' 投递 ack 被静默丢弃。
if (isEnvTruthy(process.env.CLAUDE_CODE_USE_CCR_V2)) {
// CCR v2 按定义是 SSE+POST。getTransportForUrl 在
// 相同的 env var 下返回 SSETransport但两个检查在不同文件中
// — 在这里断言不变量以便未来解耦会失败
// 而不是令人困惑地在 CCRClient 内部。
if (!(this.transport instanceof SSETransport)) {
throw new Error(
'CCR v2 requires SSETransport; check getTransportForUrl',
)
}
this.ccrClient = new CCRClient(this.transport, this.url)
const init = this.ccrClient.initialize()
this.restoredWorkerState = init.catch(() => null)
init.catch((error: unknown) => {
logForDiagnosticsNoPII('error', 'cli_worker_lifecycle_init_failed', {
reason: error instanceof CCRInitError ? error.reason : 'unknown',
})
logError(
new Error(`CCRClient initialization failed: ${errorMessage(error)}`),
)
void gracefulShutdown(1, 'other')
})
registerCleanup(async () => this.ccrClient?.close())
// 注册内部事件写入器用于 transcript 持久化。
// 设置后sessionStorage 将 transcript 消息作为 CCR v2
// 内部事件而不是 v1 Session Ingress 写入。
setInternalEventWriter((eventType, payload, options) =>
this.ccrClient!.writeInternalEvent(eventType, payload, options),
)
// 注册内部事件读取器用于会话恢复。
// 设置后hydrateFromCCRv2InternalEvents() 可以获取前台
// 和 subagent 内部事件来重建对话状态。
setInternalEventReader(
() => this.ccrClient!.readInternalEvents(),
() => this.ccrClient!.readSubagentInternalEvents(),
)
const LIFECYCLE_TO_DELIVERY = {
started: 'processing',
completed: 'processed',
} as const
setCommandLifecycleListener((uuid, state) => {
this.ccrClient?.reportDelivery(uuid, LIFECYCLE_TO_DELIVERY[state])
})
setSessionStateChangedListener((state, details) => {
this.ccrClient?.reportState(state, details)
})
setSessionMetadataChangedListener(metadata => {
this.ccrClient?.reportMetadata(metadata)
})
}
// 仅在所有回调连接后启动连接(上面的 setOnData
// 在 CCR v2 启用时 new CCRClient() 内部的 setOnEvent
void this.transport.connect()
// 在固定间隔推送静默 keep_alive 帧,以便上游代理
// 和会话入口层不会 GC 闲置的远程控制
// 会话。keep_alive 类型在任何客户端 UI 过滤之前
//Query.ts 删除它structuredIO.ts 删除它;
// web/iOS/Android 从不看他们的消息循环)。
// 间隔来自 GrowthBooktengu_bridge_poll_interval_config
// session_keepalive_interval_v2_ms默认 120s0 = 禁用。
// 仅限 Bridge修复 bridge-topology 会话上的 Envoy 空闲超时
//#21931。byoc workers 在 #21931 之前没有这个运行,不需要
// — 不同的网络路径。
const keepAliveIntervalMs =
getPollIntervalConfig().session_keepalive_interval_v2_ms
if (this.isBridge && keepAliveIntervalMs > 0) {
this.keepAliveTimer = setInterval(() => {
logForDebugging('[remote-io] keep_alive sent')
void this.write({ type: 'keep_alive' }).catch(err => {
logForDebugging(
`[remote-io] keep_alive write failed: ${errorMessage(err)}`,
)
})
}, keepAliveIntervalMs)
this.keepAliveTimer.unref?.()
}
// 为 graceful shutdown cleanup 注册
registerCleanup(async () => this.close())
// 如果提供了初始提示,通过输入流发送
if (initialPrompt) {
// 将初始提示转换为输入流格式。
// stdin 的块可能已经包含尾部换行符,所以在追加我们自己的之前
// 剥离它们以避免导致 structuredIO 解析空行的双换行问题。
// String() 处理 process.stdin 的字符串块和 Buffer 对象。
const stream = this.inputStream
void (async () => {
for await (const chunk of initialPrompt) {
stream.write(String(chunk).replace(/\n$/, '') + '\n')
}
})()
}
}
override flushInternalEvents(): Promise<void> {
return this.ccrClient?.flushInternalEvents() ?? Promise.resolve()
}
override get internalEventsPending(): number {
return this.ccrClient?.internalEventsPending ?? 0
}
/**
* 通过传输发送输出。
* 在 bridge 模式下control_request 消息始终被回显到 stdout 以便
* bridge 父进程可以检测权限请求。其他消息仅在调试模式下回显。
*/
async write(message: StdoutMessage): Promise<void> {
if (this.ccrClient) {
await this.ccrClient.writeEvent(message)
} else {
await this.transport.write(message)
}
if (this.isBridge) {
if (message.type === 'control_request' || this.isDebug) {
writeToStdout(ndjsonSafeStringify(message) + '\n')
}
}
}
/**
* 优雅地清理连接
*/
close(): void {
if (this.keepAliveTimer) {
clearInterval(this.keepAliveTimer)
this.keepAliveTimer = null
}
this.transport.close()
this.inputStream.end()
}
}

View File

@@ -0,0 +1,857 @@
import { feature } from 'bun:bundle'
import type {
ElicitResult,
JSONRPCMessage,
} from '@modelcontextprotocol/sdk/types.js'
import { randomUUID } from 'crypto'
import type { AssistantMessage } from 'src//types/message.js'
import type {
HookInput,
HookJSONOutput,
PermissionUpdate,
SDKMessage,
SDKUserMessage,
} from 'src/entrypoints/agentSdkTypes.js'
import { SDKControlElicitationResponseSchema } from 'src/entrypoints/sdk/controlSchemas.js'
import type {
SDKControlRequest,
SDKControlResponse,
StdinMessage,
StdoutMessage,
} from 'src/entrypoints/sdk/controlTypes.js'
import type { CanUseToolFn } from 'src/hooks/useCanUseTool.js'
import type { Tool, ToolUseContext } from 'src/Tool.js'
import { type HookCallback, hookJSONOutputSchema } from 'src/types/hooks.js'
import { logForDebugging } from 'src/utils/debug.js'
import { logForDiagnosticsNoPII } from 'src/utils/diagLogs.js'
import { AbortError } from 'src/utils/errors.js'
import {
type Output as PermissionToolOutput,
permissionPromptToolResultToPermissionDecision,
outputSchema as permissionToolOutputSchema,
} from 'src/utils/permissions/PermissionPromptToolResultSchema.js'
import type {
PermissionDecision,
PermissionDecisionReason,
} from 'src/utils/permissions/PermissionResult.js'
import { hasPermissionsToUseTool } from 'src/utils/permissions/permissions.js'
import { writeToStdout } from 'src/utils/process.js'
import { jsonStringify } from 'src/utils/slowOperations.js'
import { z } from 'zod/v4'
import { notifyCommandLifecycle } from '../utils/commandLifecycle.js'
import { normalizeControlMessageKeys } from '../utils/controlMessageCompat.js'
import { executePermissionRequestHooks } from '../utils/hooks.js'
import {
applyPermissionUpdates,
persistPermissionUpdates,
} from '../utils/permissions/PermissionUpdate.js'
import {
notifySessionStateChanged,
type RequiresActionDetails,
type SessionExternalMetadata,
} from '../utils/sessionState.js'
import { jsonParse } from '../utils/slowOperations.js'
import { Stream } from '../utils/stream.js'
import { ndjsonSafeStringify } from './ndjsonSafeStringify.js'
/**
* 通过 can_use_tool control_request 协议转发沙箱网络权限
* 请求时使用的合成工具名称。SDK 主机
* 将此视为正常的工具权限提示。
*/
export const SANDBOX_NETWORK_ACCESS_TOOL_NAME = 'SandboxNetworkAccess'
function serializeDecisionReason(
reason: PermissionDecisionReason | undefined,
): string | undefined {
if (!reason) {
return undefined
}
if (
(feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) &&
reason.type === 'classifier'
) {
return reason.reason
}
switch (reason.type) {
case 'rule':
case 'mode':
case 'subcommandResults':
case 'permissionPromptTool':
return undefined
case 'hook':
case 'asyncAgent':
case 'sandboxOverride':
case 'workingDir':
case 'safetyCheck':
case 'other':
return reason.reason
}
}
function buildRequiresActionDetails(
tool: Tool,
input: Record<string, unknown>,
toolUseID: string,
requestId: string,
): RequiresActionDetails {
// 每个工具的摘要方法可能在格式错误的输入上抛出;权限
// 处理不能因为错误的描述而中断。
let description: string
try {
description =
tool.getActivityDescription?.(input) ??
tool.getToolUseSummary?.(input) ??
tool.userFacingName(input)
} catch {
description = tool.name
}
return {
tool_name: tool.name,
action_description: description,
tool_use_id: toolUseID,
request_id: requestId,
input,
}
}
type PendingRequest<T> = {
resolve: (result: T) => void
reject: (error: unknown) => void
schema?: z.Schema
request: SDKControlRequest
}
/**
* 提供从 stdio 读取和写入 SDK 消息的结构化方式,
* 捕获 SDK 协议。
*/
// 要跟踪的已解析 tool_use ID 的最大数量。超过后,
// 最旧的条目被驱逐。这在非常长的会话中绑定内存,
// 同时保留足够的历史以捕获重复的 control_response 投递。
const MAX_RESOLVED_TOOL_USE_IDS = 1000
export class StructuredIO {
readonly structuredInput: AsyncGenerator<StdinMessage | SDKMessage>
private readonly pendingRequests = new Map<string, PendingRequest<unknown>>()
// 在 worker 启动时读回的 CCR external_metadata
// 当传输不恢复时为 null。由 RemoteIO 分配。
restoredWorkerState: Promise<SessionExternalMetadata | null> =
Promise.resolve(null)
private inputClosed = false
private unexpectedResponseCallback?: (
response: SDKControlResponse,
) => Promise<void>
// 跟踪已通过正常权限流程(或被 hook 中止)解决的 tool_use ID。
// 当重复的 control_response 在原始处理程序已经处理之后到达时,
// 此 Set 防止孤立处理程序重新处理它 — 这会将重复的 assistant
// 消息推入 mutableMessages 并导致 API 400 "tool_use ids must be unique"
// 错误。
private readonly resolvedToolUseIds = new Set<string>()
private prependedLines: string[] = []
private onControlRequestSent?: (request: SDKControlRequest) => void
private onControlRequestResolved?: (requestId: string) => void
// sendRequest() 和 print.ts 都排队到这里drain 循环是
// 唯一的写入者。防止 control_request 超过排队的 stream_events。
readonly outbound = new Stream<StdoutMessage>()
constructor(
private readonly input: AsyncIterable<string>,
private readonly replayUserMessages?: boolean,
) {
this.input = input
this.structuredInput = this.read()
}
/**
* 记录已解决的 tool_use ID以便忽略相同工具的晚期/重复 control_response
* 消息。
*/
private trackResolvedToolUseId(request: SDKControlRequest): void {
if (request.request.subtype === 'can_use_tool') {
this.resolvedToolUseIds.add(request.request.tool_use_id)
if (this.resolvedToolUseIds.size > MAX_RESOLVED_TOOL_USE_IDS) {
// 驱逐最旧的条目Set 按插入顺序迭代)
const first = this.resolvedToolUseIds.values().next().value
if (first !== undefined) {
this.resolvedToolUseIds.delete(first)
}
}
}
}
/** 刷新待处理的内部事件。非远程 IO 的空操作。由 RemoteIO 重写。 */
flushInternalEvents(): Promise<void> {
return Promise.resolve()
}
/** 内部事件队列深度。由 RemoteIO 重写;否则为 0。 */
get internalEventsPending(): number {
return 0
}
/**
* 排队用户轮次以在此.input 的下一条消息之前产生。
* 在迭代开始之前和中间都有效 — read() 在
* 每个产生的消息之间重新检查 prependedLines。
*/
prependUserMessage(content: string): void {
this.prependedLines.push(
jsonStringify({
type: 'user',
session_id: '',
message: { role: 'user', content },
parent_tool_use_id: null,
} satisfies SDKUserMessage) + '\n',
)
}
private async *read() {
let content = ''
// 在 for-await 之前调用一次(空 this.input 否则完全跳过
// 循环体然后每个块调用一次。prependedLines 重新检查在 while 内,
// 以便在同一块中两条消息之间推送的 prepend 仍然首先落地。
const splitAndProcess = async function* (this: StructuredIO) {
for (;;) {
if (this.prependedLines.length > 0) {
content = this.prependedLines.join('') + content
this.prependedLines = []
}
const newline = content.indexOf('\n')
if (newline === -1) break
const line = content.slice(0, newline)
content = content.slice(newline + 1)
const message = await this.processLine(line)
if (message) {
logForDiagnosticsNoPII('info', 'cli_stdin_message_parsed', {
type: message.type,
})
yield message
}
}
}.bind(this)
yield* splitAndProcess()
for await (const block of this.input) {
content += block
yield* splitAndProcess()
}
if (content) {
const message = await this.processLine(content)
if (message) {
yield message
}
}
this.inputClosed = true
for (const request of this.pendingRequests.values()) {
// 如果输入流在收到响应之前关闭,则拒绝所有待处理请求
request.reject(
new Error('Tool permission stream closed before response received'),
)
}
}
getPendingPermissionRequests() {
return Array.from(this.pendingRequests.values())
.map(entry => entry.request)
.filter(pr => pr.request.subtype === 'can_use_tool')
}
setUnexpectedResponseCallback(
callback: (response: SDKControlResponse) => Promise<void>,
): void {
this.unexpectedResponseCallback = callback
}
/**
* 注入 control_response 消息以解决待处理的权限请求。
* 由 bridge 用来将 claude.ai 的权限响应反馈到
* SDK 权限流程。
*
* 还向 SDK 消费者发送 control_cancel_request以通过 signal 中止其 canUseTool
* 回调 — 否则回调会挂起。
*/
injectControlResponse(response: SDKControlResponse): void {
const requestId = response.response?.request_id
if (!requestId) return
const request = this.pendingRequests.get(requestId)
if (!request) return
this.trackResolvedToolUseId(request.request)
this.pendingRequests.delete(requestId)
// 取消 SDK 消费者的 canUseTool 回调 — bridge 赢了。
void this.write({
type: 'control_cancel_request',
request_id: requestId,
})
if (response.response.subtype === 'error') {
request.reject(new Error(response.response.error))
} else {
const result = response.response.response
if (request.schema) {
try {
request.resolve(request.schema.parse(result))
} catch (error) {
request.reject(error)
}
} else {
request.resolve({})
}
}
}
/**
* 注册一个回调,该回调在写入 stdout 的每个 can_use_tool control_request
* 时调用。由 bridge 用来将权限
* 请求转发到 claude.ai。
*/
setOnControlRequestSent(
callback: ((request: SDKControlRequest) => void) | undefined,
): void {
this.onControlRequestSent = callback
}
/**
* 注册一个回调,该回调在 can_use_tool control_response 从
* SDK 消费者(通过 stdin到达时调用。由 bridge 用来在 SDK 消费者赢得竞速时
* 取消 claude.ai 上的过时权限提示。
*/
setOnControlRequestResolved(
callback: ((requestId: string) => void) | undefined,
): void {
this.onControlRequestResolved = callback
}
private async processLine(
line: string,
): Promise<StdinMessage | SDKMessage | undefined> {
// 跳过空行(例如来自管道 stdin 中的双换行)
if (!line) {
return undefined
}
try {
const message = normalizeControlMessageKeys(jsonParse(line)) as
| StdinMessage
| SDKMessage
if (message.type === 'keep_alive') {
// 静默忽略 keep-alive 消息
return undefined
}
if (message.type === 'update_environment_variables') {
// 直接将环境变量更新应用到 process.env。
// 由 bridge session runner 用于 auth token 刷新
//CLAUDE_CODE_SESSION_ACCESS_TOKEN必须可读
// 由 REPL 进程本身,而不仅仅是子 Bash 命令。
const keys = Object.keys(message.variables)
for (const [key, value] of Object.entries(message.variables)) {
process.env[key] = value
}
logForDebugging(
`[structuredIO] applied update_environment_variables: ${keys.join(', ')}`,
)
return undefined
}
if (message.type === 'control_response') {
// 为每个 control_response 关闭生命周期,包括重复
// 和孤立 — 孤立者不会 yield 到 print.ts 的主循环,所以这是
// 唯一看到它们的路径。uuid 是服务器注入到
// payload 中的。
const uuid =
'uuid' in message && typeof message.uuid === 'string'
? message.uuid
: undefined
if (uuid) {
notifyCommandLifecycle(uuid, 'completed')
}
const request = this.pendingRequests.get(message.response.request_id)
if (!request) {
// 检查此 tool_use 是否已通过正常权限流程解决。
// 重复的 control_response 投递(例如来自
// WebSocket 重连)在原始处理程序处理之后到达,
// 重新处理它们会将重复的 assistant 消息推入
// 对话,导致 API 400 错误。
const responsePayload =
message.response.subtype === 'success'
? message.response.response
: undefined
const toolUseID = responsePayload?.toolUseID
if (
typeof toolUseID === 'string' &&
this.resolvedToolUseIds.has(toolUseID)
) {
logForDebugging(
`Ignoring duplicate control_response for already-resolved toolUseID=${toolUseID} request_id=${message.response.request_id}`,
)
return undefined
}
if (this.unexpectedResponseCallback) {
await this.unexpectedResponseCallback(message)
}
return undefined // 忽略我们不知道的请求的响应
}
this.trackResolvedToolUseId(request.request)
this.pendingRequests.delete(message.response.request_id)
// 当 SDK 消费者解决 can_use_tool 请求时通知 bridge
// 以便它可以取消 claude.ai 上的过时权限提示。
if (
request.request.request.subtype === 'can_use_tool' &&
this.onControlRequestResolved
) {
this.onControlRequestResolved(message.response.request_id)
}
if (message.response.subtype === 'error') {
request.reject(new Error(message.response.error))
return undefined
}
const result = message.response.response
if (request.schema) {
try {
request.resolve(request.schema.parse(result))
} catch (error) {
request.reject(error)
}
} else {
request.resolve({})
}
// 启用重放时传播控制响应
if (this.replayUserMessages) {
return message
}
return undefined
}
if (
message.type !== 'user' &&
message.type !== 'control_request' &&
message.type !== 'assistant' &&
message.type !== 'system'
) {
logForDebugging(`Ignoring unknown message type: ${message.type}`, {
level: 'warn',
})
return undefined
}
if (message.type === 'control_request') {
if (!message.request) {
exitWithMessage(`Error: Missing request on control_request`)
}
return message
}
if (message.type === 'assistant' || message.type === 'system') {
return message
}
if (message.message.role !== 'user') {
exitWithMessage(
`Error: Expected message role 'user', got '${message.message.role}'`,
)
}
return message
} catch (error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error parsing streaming input line: ${line}: ${error}`)
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(1)
}
}
async write(message: StdoutMessage): Promise<void> {
writeToStdout(ndjsonSafeStringify(message) + '\n')
}
private async sendRequest<Response>(
request: SDKControlRequest['request'],
schema: z.Schema,
signal?: AbortSignal,
requestId: string = randomUUID(),
): Promise<Response> {
const message: SDKControlRequest = {
type: 'control_request',
request_id: requestId,
request,
}
if (this.inputClosed) {
throw new Error('Stream closed')
}
if (signal?.aborted) {
throw new Error('Request aborted')
}
this.outbound.enqueue(message)
if (request.subtype === 'can_use_tool' && this.onControlRequestSent) {
this.onControlRequestSent(message)
}
const aborted = () => {
this.outbound.enqueue({
type: 'control_cancel_request',
request_id: requestId,
})
// 立即拒绝未完成的 promise
// 而不等待主机确认取消。
const request = this.pendingRequests.get(requestId)
if (request) {
// 在拒绝之前将 tool_use ID 跟踪为已解决,以便
// 主机的晚期响应被孤立处理程序忽略。
this.trackResolvedToolUseId(request.request)
request.reject(new AbortError())
}
}
if (signal) {
signal.addEventListener('abort', aborted, {
once: true,
})
}
try {
return await new Promise<Response>((resolve, reject) => {
this.pendingRequests.set(requestId, {
request: {
type: 'control_request',
request_id: requestId,
request,
},
resolve: result => {
resolve(result as Response)
},
reject,
schema,
})
})
} finally {
if (signal) {
signal.removeEventListener('abort', aborted)
}
this.pendingRequests.delete(requestId)
}
}
createCanUseTool(
onPermissionPrompt?: (details: RequiresActionDetails) => void,
): CanUseToolFn {
return async (
tool: Tool,
input: { [key: string]: unknown },
toolUseContext: ToolUseContext,
assistantMessage: AssistantMessage,
toolUseID: string,
forceDecision?: PermissionDecision,
): Promise<PermissionDecision> => {
const mainPermissionResult =
forceDecision ??
(await hasPermissionsToUseTool(
tool,
input,
toolUseContext,
assistantMessage,
toolUseID,
))
// 如果工具被允许或拒绝,返回结果
if (
mainPermissionResult.behavior === 'allow' ||
mainPermissionResult.behavior === 'deny'
) {
return mainPermissionResult
}
// 与 SDK 权限并行运行 PermissionRequest hooks。
// 在终端 CLI 中hooks 与交互式
// 提示竞速,以便例如带有 --delay 20 的 hook 不会阻塞 UI。
// 我们在这里需要相同的行为SDK 主机VS Code 等)立即显示
// 其权限对话框,而 hooks 在后台运行。
// 先解决者获胜;失败者被取消/忽略。
// AbortController 用于在 hook 首先决定时取消 SDK 请求
const hookAbortController = new AbortController()
const parentSignal = toolUseContext.abortController.signal
// 将父中止转发到我们的本地控制器
const onParentAbort = () => hookAbortController.abort()
parentSignal.addEventListener('abort', onParentAbort, { once: true })
try {
// 启动 hook 评估(后台运行)
const hookPromise = executePermissionRequestHooksForSDK(
tool.name,
toolUseID,
input,
toolUseContext,
mainPermissionResult.suggestions,
).then(decision => ({ source: 'hook' as const, decision }))
// 立即启动 SDK 权限提示(不等待 hooks
const requestId = randomUUID()
onPermissionPrompt?.(
buildRequiresActionDetails(tool, input, toolUseID, requestId),
)
const sdkPromise = this.sendRequest<PermissionToolOutput>(
{
subtype: 'can_use_tool',
tool_name: tool.name,
input,
permission_suggestions: mainPermissionResult.suggestions,
blocked_path: mainPermissionResult.blockedPath,
decision_reason: serializeDecisionReason(
mainPermissionResult.decisionReason,
),
tool_use_id: toolUseID,
agent_id: toolUseContext.agentId,
},
permissionToolOutputSchema(),
hookAbortController.signal,
requestId,
).then(result => ({ source: 'sdk' as const, result }))
// 竞速hook 完成 vs SDK 提示响应。
// hook promise 总是解决(从不拒绝),如果没有 hook 做出决定则返回
// undefined。
const winner = await Promise.race([hookPromise, sdkPromise])
if (winner.source === 'hook') {
if (winner.decision) {
// Hook 决定了 — 中止待处理的 SDK 请求。
// 抑制来自 sdkPromise 的预期 AbortError 拒绝。
sdkPromise.catch(() => {})
hookAbortController.abort()
return winner.decision
}
// Hook 通过了(无决定)— 等待 SDK 提示
const sdkResult = await sdkPromise
return permissionPromptToolResultToPermissionDecision(
sdkResult.result,
tool,
input,
toolUseContext,
)
}
// SDK 提示先响应 — 使用其结果hook 在
// 后台运行但其结果将被忽略)
return permissionPromptToolResultToPermissionDecision(
winner.result,
tool,
input,
toolUseContext,
)
} catch (error) {
return permissionPromptToolResultToPermissionDecision(
{
behavior: 'deny',
message: `Tool permission request failed: ${error}`,
toolUseID,
},
tool,
input,
toolUseContext,
)
} finally {
// 仅当没有其他权限提示
// 挂起时转换回 'running'(并发工具执行可以有多个 in-flight
if (this.getPendingPermissionRequests().length === 0) {
notifySessionStateChanged('running')
}
parentSignal.removeEventListener('abort', onParentAbort)
}
}
}
createHookCallback(callbackId: string, timeout?: number): HookCallback {
return {
type: 'callback',
timeout,
callback: async (
input: HookInput,
toolUseID: string | null,
abort: AbortSignal | undefined,
): Promise<HookJSONOutput> => {
try {
const result = await this.sendRequest<HookJSONOutput>(
{
subtype: 'hook_callback',
callback_id: callbackId,
input,
tool_use_id: toolUseID || undefined,
},
hookJSONOutputSchema(),
abort,
)
return result
} catch (error) {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(`Error in hook callback ${callbackId}:`, error)
return {}
}
},
}
}
/**
* 向 SDK 消费者发送 elicitation 请求并返回响应。
*/
async handleElicitation(
serverName: string,
message: string,
requestedSchema?: Record<string, unknown>,
signal?: AbortSignal,
mode?: 'form' | 'url',
url?: string,
elicitationId?: string,
): Promise<ElicitResult> {
try {
const result = await this.sendRequest<ElicitResult>(
{
subtype: 'elicitation',
mcp_server_name: serverName,
message,
mode,
url,
elicitation_id: elicitationId,
requested_schema: requestedSchema,
},
SDKControlElicitationResponseSchema(),
signal,
)
return result
} catch {
return { action: 'cancel' as const }
}
}
/**
* 创建一个 SandboxAskCallback将沙箱网络权限
* 请求转发到 SDK 主机作为 can_use_tool control_requests。
*
* 这利用现有的 can_use_tool 协议与合成
* 工具名称,以便 SDK 主机VS Code、CCR 等)可以提示用户
* 网络访问而无需新的协议子类型。
*/
createSandboxAskCallback(): (hostPattern: {
host: string
port?: number
}) => Promise<boolean> {
return async (hostPattern): Promise<boolean> => {
try {
const result = await this.sendRequest<PermissionToolOutput>(
{
subtype: 'can_use_tool',
tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME,
input: { host: hostPattern.host },
tool_use_id: randomUUID(),
description: `Allow network connection to ${hostPattern.host}?`,
},
permissionToolOutputSchema(),
)
return result.behavior === 'allow'
} catch {
// 如果请求失败(流关闭、中止等),拒绝连接
return false
}
}
}
/**
* 向 SDK 服务器发送 MCP 消息并等待响应
*/
async sendMcpMessage(
serverName: string,
message: JSONRPCMessage,
): Promise<JSONRPCMessage> {
const response = await this.sendRequest<{ mcp_response: JSONRPCMessage }>(
{
subtype: 'mcp_message',
server_name: serverName,
message,
},
z.object({
mcp_response: z.any() as z.Schema<JSONRPCMessage>,
}),
)
return response.mcp_response
}
}
function exitWithMessage(message: string): never {
// biome-ignore lint/suspicious/noConsole:: intentional console output
console.error(message)
// eslint-disable-next-line custom-rules/no-process-exit
process.exit(1)
}
/**
* 执行 PermissionRequest hooks 并在做出决定时返回决定。
* 如果没有 hook 做出决定则返回 undefined。
*/
async function executePermissionRequestHooksForSDK(
toolName: string,
toolUseID: string,
input: Record<string, unknown>,
toolUseContext: ToolUseContext,
suggestions: PermissionUpdate[] | undefined,
): Promise<PermissionDecision | undefined> {
const appState = toolUseContext.getAppState()
const permissionMode = appState.toolPermissionContext.mode
// 直接迭代生成器而不是使用 `all`
const hookGenerator = executePermissionRequestHooks(
toolName,
toolUseID,
input,
toolUseContext,
permissionMode,
suggestions,
toolUseContext.abortController.signal,
)
for await (const hookResult of hookGenerator) {
if (
hookResult.permissionRequestResult &&
(hookResult.permissionRequestResult.behavior === 'allow' ||
hookResult.permissionRequestResult.behavior === 'deny')
) {
const decision = hookResult.permissionRequestResult
if (decision.behavior === 'allow') {
const finalInput = decision.updatedInput || input
// 如果 hook 提供则应用权限更新("始终允许"
const permissionUpdates = decision.updatedPermissions ?? []
if (permissionUpdates.length > 0) {
persistPermissionUpdates(permissionUpdates)
const currentAppState = toolUseContext.getAppState()
const updatedContext = applyPermissionUpdates(
currentAppState.toolPermissionContext,
permissionUpdates,
)
// 通过 setAppState 更新权限上下文
toolUseContext.setAppState(prev => {
if (prev.toolPermissionContext === updatedContext) return prev
return { ...prev, toolPermissionContext: updatedContext }
})
}
return {
behavior: 'allow',
updatedInput: finalInput,
userModified: false,
decisionReason: {
type: 'hook',
hookName: 'PermissionRequest',
},
}
} else {
// Hook 拒绝了权限
return {
behavior: 'deny',
message:
decision.message || 'Permission denied by PermissionRequest hook',
decisionReason: {
type: 'hook',
hookName: 'PermissionRequest',
},
}
}
}
}
return undefined
}

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const addDir = {
type: 'local-jsx',
name: 'add-dir',
description: 'Add a new working directory',
argumentHint: '<path>',
load: () => import('./add-dir.js'),
} satisfies Command
export default addDir

View File

@@ -0,0 +1,110 @@
import chalk from 'chalk'
import { stat } from 'fs/promises'
import { dirname, resolve } from 'path'
import type { ToolPermissionContext } from '../../Tool.js'
import { getErrnoCode } from '../../utils/errors.js'
import { expandPath } from '../../utils/path.js'
import {
allWorkingDirectories,
pathInWorkingPath,
} from '../../utils/permissions/filesystem.js'
export type AddDirectoryResult =
| {
resultType: 'success'
absolutePath: string
}
| {
resultType: 'emptyPath'
}
| {
resultType: 'pathNotFound' | 'notADirectory'
directoryPath: string
absolutePath: string
}
| {
resultType: 'alreadyInWorkingDirectory'
directoryPath: string
workingDir: string
}
export async function validateDirectoryForWorkspace(
directoryPath: string,
permissionContext: ToolPermissionContext,
): Promise<AddDirectoryResult> {
if (!directoryPath) {
return {
resultType: 'emptyPath',
}
}
// resolve() 去掉 expandPath 可能在绝对输入上留下的尾部斜杠,
// 所以 /foo 和 /foo/ 映射到相同的存储键CC-33
const absolutePath = resolve(expandPath(directoryPath))
// 检查路径是否存在且是目录(单个系统调用)
try {
const stats = await stat(absolutePath)
if (!stats.isDirectory()) {
return {
resultType: 'notADirectory',
directoryPath,
absolutePath,
}
}
} catch (e: unknown) {
const code = getErrnoCode(e)
// 匹配之前的 existsSync() 语义:将任何这些视为"未找到"
// 而不是重新抛出。特别是 EACCES/EPERM 在设置配置的
// 附加目录不可访问时不得导致启动崩溃。
if (
code === 'ENOENT' ||
code === 'ENOTDIR' ||
code === 'EACCES' ||
code === 'EPERM'
) {
return {
resultType: 'pathNotFound',
directoryPath,
absolutePath,
}
}
throw e
}
// 获取当前权限上下文
const currentWorkingDirs = allWorkingDirectories(permissionContext)
// 检查是否已在现有工作目录内
for (const workingDir of currentWorkingDirs) {
if (pathInWorkingPath(absolutePath, workingDir)) {
return {
resultType: 'alreadyInWorkingDirectory',
directoryPath,
workingDir,
}
}
}
return {
resultType: 'success',
absolutePath,
}
}
export function addDirHelpMessage(result: AddDirectoryResult): string {
switch (result.resultType) {
case 'emptyPath':
return 'Please provide a directory path.'
case 'pathNotFound':
return `Path ${chalk.bold(result.absolutePath)} was not found.`
case 'notADirectory': {
const parentDir = dirname(result.absolutePath)
return `${chalk.bold(result.directoryPath)} is not a directory. Did you mean to add the parent directory ${chalk.bold(parentDir)}?`
}
case 'alreadyInWorkingDirectory':
return `${chalk.bold(result.directoryPath)} is already accessible within the existing working directory ${chalk.bold(result.workingDir)}.`
case 'success':
return `Added ${chalk.bold(result.absolutePath)} as a working directory.`
}
}

View File

@@ -0,0 +1,109 @@
import type { Command } from '../commands.js'
import type { LocalCommandCall } from '../types/command.js'
import {
canUserConfigureAdvisor,
isValidAdvisorModel,
modelSupportsAdvisor,
} from '../utils/advisor.js'
import {
getDefaultMainLoopModelSetting,
normalizeModelStringForAPI,
parseUserSpecifiedModel,
} from '../utils/model/model.js'
import { validateModel } from '../utils/model/validateModel.js'
import { updateSettingsForSource } from '../utils/settings/settings.js'
const call: LocalCommandCall = async (args, context) => {
const arg = args.trim().toLowerCase()
const baseModel = parseUserSpecifiedModel(
context.getAppState().mainLoopModel ?? getDefaultMainLoopModelSetting(),
)
if (!arg) {
const current = context.getAppState().advisorModel
if (!current) {
return {
type: 'text',
value:
'Advisor: not set\nUse "/advisor <model>" to enable (e.g. "/advisor opus").',
}
}
if (!modelSupportsAdvisor(baseModel)) {
return {
type: 'text',
value: `Advisor: ${current} (inactive)\nThe current model (${baseModel}) does not support advisors.`,
}
}
return {
type: 'text',
value: `Advisor: ${current}\nUse "/advisor unset" to disable or "/advisor <model>" to change.`,
}
}
if (arg === 'unset' || arg === 'off') {
const prev = context.getAppState().advisorModel
context.setAppState(s => {
if (s.advisorModel === undefined) return s
return { ...s, advisorModel: undefined }
})
updateSettingsForSource('userSettings', { advisorModel: undefined })
return {
type: 'text',
value: prev
? `Advisor disabled (was ${prev}).`
: 'Advisor already unset.',
}
}
const normalizedModel = normalizeModelStringForAPI(arg)
const resolvedModel = parseUserSpecifiedModel(arg)
const { valid, error } = await validateModel(resolvedModel)
if (!valid) {
return {
type: 'text',
value: error
? `Invalid advisor model: ${error}`
: `Unknown model: ${arg} (${resolvedModel})`,
}
}
if (!isValidAdvisorModel(resolvedModel)) {
return {
type: 'text',
value: `The model ${arg} (${resolvedModel}) cannot be used as an advisor`,
}
}
context.setAppState(s => {
if (s.advisorModel === normalizedModel) return s
return { ...s, advisorModel: normalizedModel }
})
updateSettingsForSource('userSettings', { advisorModel: normalizedModel })
if (!modelSupportsAdvisor(baseModel)) {
return {
type: 'text',
value: `Advisor set to ${normalizedModel}.\nNote: Your current model (${baseModel}) does not support advisors. Switch to a supported model to use the advisor.`,
}
}
return {
type: 'text',
value: `Advisor set to ${normalizedModel}.`,
}
}
const advisor = {
type: 'local',
name: 'advisor',
description: 'Configure the advisor model',
argumentHint: '[<model>|off]',
isEnabled: () => canUserConfigureAdvisor(),
get isHidden() {
return !canUserConfigureAdvisor()
},
supportsNonInteractive: true,
load: () => Promise.resolve({ call }),
} satisfies Command
export default advisor

View File

@@ -0,0 +1,10 @@
import type { Command } from '../../commands.js'
const agents = {
type: 'local-jsx',
name: 'agents',
description: 'Manage agent configurations',
load: () => import('./agents.js'),
} satisfies Command
export default agents

View File

@@ -0,0 +1,295 @@
import { randomUUID, type UUID } from 'crypto'
import { mkdir, readFile, writeFile } from 'fs/promises'
import { getOriginalCwd, getSessionId } from '../../bootstrap/state.js'
import type { LocalJSXCommandContext } from '../../commands.js'
import { logEvent } from '../../services/analytics/index.js'
import type { LocalJSXCommandOnDone } from '../../types/command.js'
import type {
ContentReplacementEntry,
Entry,
LogOption,
SerializedMessage,
TranscriptMessage,
} from '../../types/logs.js'
import { parseJSONL } from '../../utils/json.js'
import {
getProjectDir,
getTranscriptPath,
getTranscriptPathForSession,
isTranscriptMessage,
saveCustomTitle,
searchSessionsByCustomTitle,
} from '../../utils/sessionStorage.js'
import { jsonStringify } from '../../utils/slowOperations.js'
import { escapeRegExp } from '../../utils/stringUtils.js'
type TranscriptEntry = TranscriptMessage & {
forkedFrom?: {
sessionId: string
messageUuid: UUID
}
}
/**
* 从第一个用户消息派生单行标题基础。
* 折叠空白 — 多行第一个消息(粘贴的堆栈、代码)
* 否则会流入保存的标题并破坏恢复提示。
*/
export function deriveFirstPrompt(
firstUserMessage: Extract<SerializedMessage, { type: 'user' }> | undefined,
): string {
const content = firstUserMessage?.message?.content
if (!content) return 'Branched conversation'
const raw =
typeof content === 'string'
? content
: content.find(
(block): block is { type: 'text'; text: string } =>
block.type === 'text',
)?.text
if (!raw) return 'Branched conversation'
return (
raw.replace(/\s+/g, ' ').trim().slice(0, 100) || 'Branched conversation'
)
}
/**
* 通过从转录文件复制来创建当前对话的分支。
* 保留所有原始元数据时间戳、gitBranch 等),同时更新
* sessionId 并添加 forkedFrom 可追溯性。
*/
async function createFork(customTitle?: string): Promise<{
sessionId: UUID
title: string | undefined
forkPath: string
serializedMessages: SerializedMessage[]
contentReplacementRecords: ContentReplacementEntry['replacements']
}> {
const forkSessionId = randomUUID() as UUID
const originalSessionId = getSessionId()
const projectDir = getProjectDir(getOriginalCwd())
const forkSessionPath = getTranscriptPathForSession(forkSessionId)
const currentTranscriptPath = getTranscriptPath()
// 确保项目目录存在
await mkdir(projectDir, { recursive: true, mode: 0o700 })
// 读取当前转录文件
let transcriptContent: Buffer
try {
transcriptContent = await readFile(currentTranscriptPath)
} catch {
throw new Error('No conversation to branch')
}
if (transcriptContent.length === 0) {
throw new Error('No conversation to branch')
}
// 解析所有转录条目(消息 + 如 content-replacement 的元数据条目)
const entries = parseJSONL<Entry>(transcriptContent)
// 仅过滤到主对话消息(排除侧链和非消息条目)
const mainConversationEntries = entries.filter(
(entry): entry is TranscriptMessage =>
isTranscriptMessage(entry) && !entry.isSidechain,
)
// 原始会话的内容替换条目。这些记录了哪些
// tool_result 块被每消息预算的预览替换。
// 如果不在 fork JSONL 中,`claude -r {forkId}` 会用空的 replacements Map 重建状态
// → 之前替换的结果被分类为 FROZEN 并作为完整内容发送(提示词缓存未命中 + 永久超出)。
// sessionId 必须重写,因为 loadTranscriptFile 按
// 会话消息的 sessionId 键查找。
const contentReplacementRecords = entries
.filter(
(entry): entry is ContentReplacementEntry =>
entry.type === 'content-replacement' &&
entry.sessionId === originalSessionId,
)
.flatMap(entry => entry.replacements)
if (mainConversationEntries.length === 0) {
throw new Error('No messages to branch')
}
// 使用新的 sessionId 和保留的元数据构建分支条目
let parentUuid: UUID | null = null
const lines: string[] = []
const serializedMessages: SerializedMessage[] = []
for (const entry of mainConversationEntries) {
// 创建保留所有原始元数据的分支转录条目
const forkedEntry: TranscriptEntry = {
...entry,
sessionId: forkSessionId,
parentUuid,
isSidechain: false,
forkedFrom: {
sessionId: originalSessionId,
messageUuid: entry.uuid,
},
}
// 为 LogOption 构建序列化消息
const serialized: SerializedMessage = {
...entry,
sessionId: forkSessionId,
}
serializedMessages.push(serialized)
lines.push(jsonStringify(forkedEntry))
if (entry.type !== 'progress') {
parentUuid = entry.uuid
}
}
// 追加内容替换条目(如果有),使用 fork 的 sessionId。
// 写为单个条目(与 insertContentReplacement 相同的形状),以便
// loadTranscriptFile 的 content-replacement 分支拾取它。
if (contentReplacementRecords.length > 0) {
const forkedReplacementEntry: ContentReplacementEntry = {
type: 'content-replacement',
sessionId: forkSessionId,
replacements: contentReplacementRecords,
}
lines.push(jsonStringify(forkedReplacementEntry))
}
// 写入 fork 会话文件
await writeFile(forkSessionPath, lines.join('\n') + '\n', {
encoding: 'utf8',
mode: 0o600,
})
return {
sessionId: forkSessionId,
title: customTitle,
forkPath: forkSessionPath,
serializedMessages,
contentReplacementRecords,
}
}
/**
* 通过检查与会话名称的冲突来生成唯一的 fork 名称。
* 如果"baseName (Branch)"已存在,则尝试"baseName (Branch 2)"、"baseName (Branch 3)"等。
*/
async function getUniqueForkName(baseName: string): Promise<string> {
const candidateName = `${baseName} (Branch)`
// 检查这个确切名称是否已存在
const existingWithExactName = await searchSessionsByCustomTitle(
candidateName,
{ exact: true },
)
if (existingWithExactName.length === 0) {
return candidateName
}
// 名称冲突 — 找到一个唯一的编号后缀
// 搜索以基本模式开头的所有会话
const existingForks = await searchSessionsByCustomTitle(`${baseName} (Branch`)
// 提取现有的 fork 编号以找到下一个可用编号
const usedNumbers = new Set<number>([1]) // 将" (Branch)"视为编号 1
const forkNumberPattern = new RegExp(
`^${escapeRegExp(baseName)} \\(Branch(?: (\\d+))?\\)$`,
)
for (const session of existingForks) {
const match = session.customTitle?.match(forkNumberPattern)
if (match) {
if (match[1]) {
usedNumbers.add(parseInt(match[1], 10))
} else {
usedNumbers.add(1) // " (Branch)" 无编号视为 1
}
}
}
// 找到下一个可用编号
let nextNumber = 2
while (usedNumbers.has(nextNumber)) {
nextNumber++
}
return `${baseName} (Branch ${nextNumber})`
}
export async function call(
onDone: LocalJSXCommandOnDone,
context: LocalJSXCommandContext,
args: string,
): Promise<React.ReactNode> {
const customTitle = args?.trim() || undefined
const originalSessionId = getSessionId()
try {
const {
sessionId,
title,
forkPath,
serializedMessages,
contentReplacementRecords,
} = await createFork(customTitle)
// 为恢复构建 LogOption
const now = new Date()
const firstPrompt = deriveFirstPrompt(
serializedMessages.find(m => m.type === 'user'),
)
// 保存自定义标题 — 使用提供的标题或 firstPrompt 作为默认值
// 这确保 /status 和 /resume 显示相同的会话名称
// 始终添加" (Branch)"后缀以使其成为分支会话
// 通过添加编号后缀处理冲突(例如" (Branch 2)"、" (Branch 3)"
const baseName = title ?? firstPrompt
const effectiveTitle = await getUniqueForkName(baseName)
await saveCustomTitle(sessionId, effectiveTitle, forkPath)
logEvent('tengu_conversation_forked', {
message_count: serializedMessages.length,
has_custom_title: !!title,
})
const forkLog: LogOption = {
date: now.toISOString().split('T')[0]!,
messages: serializedMessages,
fullPath: forkPath,
value: now.getTime(),
created: now,
modified: now,
firstPrompt,
messageCount: serializedMessages.length,
isSidechain: false,
sessionId,
customTitle: effectiveTitle,
contentReplacements: contentReplacementRecords,
}
// 恢复到 fork
const titleInfo = title ? ` "${title}"` : ''
const resumeHint = `\nTo resume the original: claude -r ${originalSessionId}`
const successMessage = `Branched conversation${titleInfo}. You are now in the branch.${resumeHint}`
if (context.resume) {
await context.resume(sessionId, forkLog, 'fork')
onDone(successMessage, { display: 'system' })
} else {
// 如果恢复不可用则回退
onDone(
`Branched conversation${titleInfo}. Resume with: /resume ${sessionId}`,
)
}
return null
} catch (error) {
const message =
error instanceof Error ? error.message : 'Unknown error occurred'
onDone(`Failed to branch conversation: ${message}`)
return null
}
}

View File

@@ -0,0 +1,14 @@
import { feature } from 'bun:bundle'
import type { Command } from '../../commands.js'
const branch = {
type: 'local-jsx',
name: 'branch',
// 'fork' alias only when /fork doesn't exist as its own command
aliases: feature('FORK_SUBAGENT') ? [] : ['fork'],
description: 'Create a branch of the current conversation at this point',
argumentHint: '[name]',
load: () => import('./branch.js'),
} satisfies Command
export default branch

View File

@@ -0,0 +1,199 @@
import { getBridgeDebugHandle } from '../bridge/bridgeDebug.js'
import type { Command } from '../commands.js'
import type { LocalCommandCall } from '../types/command.js'
/**
* Ant 专用:注入 bridge 失败状态以手动测试恢复路径。
*
* /bridge-kick close 1002 — 使用 code 1002 触发 ws_closed
* /bridge-kick close 1006 — 使用 code 1006 触发 ws_closed
* /bridge-kick poll 404 — 下一次 poll 抛出 404/not_found_error
* /bridge-kick poll 404 <type> — 下一次 poll 抛出带 error_type 的 404
* /bridge-kick poll 401 — 下一次 poll 抛出 401auth
* /bridge-kick poll transient — 下一次 poll 抛出类 axios 拒绝
* /bridge-kick register fail — 下一次 register在 doReconnect 内部)瞬时失败
* /bridge-kick register fail 3 — 接下来 3 次 register 瞬时失败
* /bridge-kick register fatal — 下一次 register 403终端
* /bridge-kick reconnect-session fail — POST /bridge/reconnect 失败(→ 策略 2
* /bridge-kick heartbeat 401 — 下一次 heartbeat 401JWT 过期)
* /bridge-kick reconnect — 直接调用 doReconnect= SIGUSR2
* /bridge-kick status — 打印当前 bridge 状态
*
* 工作流程:连接 Remote Control运行子命令`tail -f debug.log`
* 并观察 [bridge:repl] / [bridge:debug] 行以获取恢复反应。
*
* 复合序列 — BQ 数据中的失败模式是链,而不是单个事件。队列故障然后触发:
*
* # #22148 残留ws_closed → register transient-blips → teardown?
* /bridge-kick register fail 2
* /bridge-kick close 1002
* → 预期doReconnect 尝试 register失败返回 false → teardown
* (展示需要修复的重试间隙)
*
* # 死门poll 404/not_found_error → onEnvironmentLost 是否触发?
* /bridge-kick poll 404
* → 预期tengu_bridge_repl_fatal_error门已死 — 147K/周)
* 修复后tengu_bridge_repl_env_lost → doReconnect
*/
const USAGE = `/bridge-kick <subcommand>
close <code> fire ws_closed with the given code (e.g. 1002)
poll <status> [type] next poll throws BridgeFatalError(status, type)
poll transient next poll throws axios-style rejection (5xx/net)
register fail [N] next N registers transient-fail (default 1)
register fatal next register 403s (terminal)
reconnect-session fail next POST /bridge/reconnect fails
heartbeat <status> next heartbeat throws BridgeFatalError(status)
reconnect call reconnectEnvironmentWithSession directly
status print bridge state`
const call: LocalCommandCall = async args => {
const h = getBridgeDebugHandle()
if (!h) {
return {
type: 'text',
value:
'No bridge debug handle registered. Remote Control must be connected (USER_TYPE=ant).',
}
}
const [sub, a, b] = args.trim().split(/\s+/)
switch (sub) {
case 'close': {
const code = Number(a)
if (!Number.isFinite(code)) {
return { type: 'text', value: `close: need a numeric code\n${USAGE}` }
}
h.fireClose(code)
return {
type: 'text',
value: `Fired transport close(${code}). Watch debug.log for [bridge:repl] recovery.`,
}
}
case 'poll': {
if (a === 'transient') {
h.injectFault({
method: 'pollForWork',
kind: 'transient',
status: 503,
count: 1,
})
h.wakePollLoop()
return {
type: 'text',
value:
'Next poll will throw a transient (axios rejection). Poll loop woken.',
}
}
const status = Number(a)
if (!Number.isFinite(status)) {
return {
type: 'text',
value: `poll: need 'transient' or a status code\n${USAGE}`,
}
}
// 默认为服务器实际发送的内容BQ 验证),
// 所以 `/bridge-kick poll 404` 再现真实的 147K/周状态。
const errorType =
b ?? (status === 404 ? 'not_found_error' : 'authentication_error')
h.injectFault({
method: 'pollForWork',
kind: 'fatal',
status,
errorType,
count: 1,
})
h.wakePollLoop()
return {
type: 'text',
value: `Next poll will throw BridgeFatalError(${status}, ${errorType}). Poll loop woken.`,
}
}
case 'register': {
if (a === 'fatal') {
h.injectFault({
method: 'registerBridgeEnvironment',
kind: 'fatal',
status: 403,
errorType: 'permission_error',
count: 1,
})
return {
type: 'text',
value:
'Next registerBridgeEnvironment will 403. Trigger with close/reconnect.',
}
}
const n = Number(b) || 1
h.injectFault({
method: 'registerBridgeEnvironment',
kind: 'transient',
status: 503,
count: n,
})
return {
type: 'text',
value: `Next ${n} registerBridgeEnvironment call(s) will transient-fail. Trigger with close/reconnect.`,
}
}
case 'reconnect-session': {
h.injectFault({
method: 'reconnectSession',
kind: 'fatal',
status: 404,
errorType: 'not_found_error',
count: 2,
})
return {
type: 'text',
value:
'Next 2 POST /bridge/reconnect calls will 404. doReconnect Strategy 1 falls through to Strategy 2.',
}
}
case 'heartbeat': {
const status = Number(a) || 401
h.injectFault({
method: 'heartbeatWork',
kind: 'fatal',
status,
errorType: status === 401 ? 'authentication_error' : 'not_found_error',
count: 1,
})
return {
type: 'text',
value: `Next heartbeat will ${status}. Watch for onHeartbeatFatal → work-state teardown.`,
}
}
case 'reconnect': {
h.forceReconnect()
return {
type: 'text',
value: 'Called reconnectEnvironmentWithSession(). Watch debug.log.',
}
}
case 'status': {
return { type: 'text', value: h.describe() }
}
default:
return { type: 'text', value: USAGE }
}
}
const bridgeKick = {
type: 'local',
name: 'bridge-kick',
description: 'Inject bridge failure states for manual recovery testing',
isEnabled: () => process.env.USER_TYPE === 'ant',
supportsNonInteractive: false,
load: () => Promise.resolve({ call }),
} satisfies Command
export default bridgeKick

View File

@@ -0,0 +1,26 @@
import { feature } from 'bun:bundle'
import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'
import type { Command } from '../../commands.js'
function isEnabled(): boolean {
if (!feature('BRIDGE_MODE')) {
return false
}
return isBridgeEnabled()
}
const bridge = {
type: 'local-jsx',
name: 'remote-control',
aliases: ['rc'],
description: 'Connect this terminal for remote-control sessions',
argumentHint: '[name]',
isEnabled,
get isHidden() {
return !isEnabled()
},
immediate: true,
load: () => import('./bridge.js'),
} satisfies Command
export default bridge

View File

@@ -0,0 +1,130 @@
import { feature } from 'bun:bundle'
import { z } from 'zod/v4'
import { getKairosActive, setUserMsgOptIn } from '../bootstrap/state.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../services/analytics/index.js'
import type { ToolUseContext } from '../Tool.js'
import { isBriefEntitled } from '../tools/BriefTool/BriefTool.js'
import { BRIEF_TOOL_NAME } from '../tools/BriefTool/prompt.js'
import type {
Command,
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../types/command.js'
import { lazySchema } from '../utils/lazySchema.js'
// Zod guards against fat-fingered GB pushes (same pattern as pollConfig.ts /
// cronScheduler.ts). A malformed config falls back to DEFAULT_BRIEF_CONFIG
// entirely rather than being partially trusted.
const briefConfigSchema = lazySchema(() =>
z.object({
enable_slash_command: z.boolean(),
}),
)
type BriefConfig = z.infer<ReturnType<typeof briefConfigSchema>>
const DEFAULT_BRIEF_CONFIG: BriefConfig = {
enable_slash_command: false,
}
// No TTL — this gate controls slash-command *visibility*, not a kill switch.
// CACHED_MAY_BE_STALE still has one background-update flip (first call kicks
// off fetch; second call sees fresh value), but no additional flips after that.
// The tool-availability gate (tengu_kairos_brief in isBriefEnabled) keeps its
// 5-min TTL because that one IS a kill switch.
function getBriefConfig(): BriefConfig {
const raw = getFeatureValue_CACHED_MAY_BE_STALE<unknown>(
'tengu_kairos_brief_config',
DEFAULT_BRIEF_CONFIG,
)
const parsed = briefConfigSchema().safeParse(raw)
return parsed.success ? parsed.data : DEFAULT_BRIEF_CONFIG
}
const brief = {
type: 'local-jsx',
name: 'brief',
description: 'Toggle brief-only mode',
isEnabled: () => {
if (feature('KAIROS') || feature('KAIROS_BRIEF')) {
return getBriefConfig().enable_slash_command
}
return false
},
immediate: true,
load: () =>
Promise.resolve({
async call(
onDone: LocalJSXCommandOnDone,
context: ToolUseContext & LocalJSXCommandContext,
): Promise<React.ReactNode> {
const current = context.getAppState().isBriefOnly
const newState = !current
// Entitlement check only gates the on-transition — off is always
// allowed so a user whose GB gate flipped mid-session isn't stuck.
if (newState && !isBriefEntitled()) {
logEvent('tengu_brief_mode_toggled', {
enabled: false,
gated: true,
source:
'slash_command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
onDone('Brief tool is not enabled for your account', {
display: 'system',
})
return null
}
// Two-way: userMsgOptIn tracks isBriefOnly so the tool is available
// exactly when brief mode is on. This invalidates prompt cache on
// each toggle (tool list changes), but a stale tool list is worse —
// when /brief is enabled mid-session the model was previously left
// without the tool, emitting plain text the filter hides.
setUserMsgOptIn(newState)
context.setAppState(prev => {
if (prev.isBriefOnly === newState) return prev
return { ...prev, isBriefOnly: newState }
})
logEvent('tengu_brief_mode_toggled', {
enabled: newState,
gated: false,
source:
'slash_command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// The tool list change alone isn't a strong enough signal mid-session
// (model may keep emitting plain text from inertia, or keep calling a
// tool that just vanished). Inject an explicit reminder into the next
// turn's context so the transition is unambiguous.
// Skip when Kairos is active: isBriefEnabled() short-circuits on
// getKairosActive() so the tool never actually leaves the list, and
// the Kairos system prompt already mandates SendUserMessage.
// Inline <system-reminder> wrap — importing wrapInSystemReminder from
// utils/messages.ts pulls constants/xml.ts into the bridge SDK bundle
// via this module's import chain, tripping the excluded-strings check.
const metaMessages = getKairosActive()
? undefined
: [
`<system-reminder>\n${
newState
? `Brief mode is now enabled. Use the ${BRIEF_TOOL_NAME} tool for all user-facing output — plain text outside it is hidden from the user's view.`
: `Brief mode is now disabled. The ${BRIEF_TOOL_NAME} tool is no longer available — reply with plain text.`
}\n</system-reminder>`,
]
onDone(
newState ? 'Brief-only mode enabled' : 'Brief-only mode disabled',
{ display: 'system', metaMessages },
)
return null
},
}),
} satisfies Command
export default brief

View File

@@ -0,0 +1,13 @@
import type { Command } from '../../commands.js'
const btw = {
type: 'local-jsx',
name: 'btw',
description:
'Ask a quick side question without interrupting the main conversation',
immediate: true,
argumentHint: '<question>',
load: () => import('./btw.js'),
} satisfies Command
export default btw

View File

@@ -0,0 +1,13 @@
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import type { Command } from '../../commands.js'
const command: Command = {
name: 'chrome',
description: 'Claude in Chrome (Beta) settings',
availability: ['claude-ai'],
isEnabled: () => !getIsNonInteractiveSession(),
type: 'local-jsx',
load: () => import('./chrome.js'),
}
export default command

View File

@@ -0,0 +1,144 @@
/**
* Session cache clearing utilities.
* This module is imported at startup by main.tsx, so keep imports minimal.
*/
import { feature } from 'bun:bundle'
import {
clearInvokedSkills,
setLastEmittedDate,
} from '../../bootstrap/state.js'
import { clearCommandsCache } from '../../commands.js'
import { getSessionStartDate } from '../../constants/common.js'
import {
getGitStatus,
getSystemContext,
getUserContext,
setSystemPromptInjection,
} from '../../context.js'
import { clearFileSuggestionCaches } from '../../hooks/fileSuggestions.js'
import { clearAllPendingCallbacks } from '../../hooks/useSwarmPermissionPoller.js'
import { clearAllDumpState } from '../../services/api/dumpPrompts.js'
import { resetPromptCacheBreakDetection } from '../../services/api/promptCacheBreakDetection.js'
import { clearAllSessions } from '../../services/api/sessionIngress.js'
import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js'
import { resetAllLSPDiagnosticState } from '../../services/lsp/LSPDiagnosticRegistry.js'
import { clearTrackedMagicDocs } from '../../services/MagicDocs/magicDocs.js'
import { clearDynamicSkills } from '../../skills/loadSkillsDir.js'
import { resetSentSkillNames } from '../../utils/attachments.js'
import { clearCommandPrefixCaches } from '../../utils/bash/commands.js'
import { resetGetMemoryFilesCache } from '../../utils/claudemd.js'
import { clearRepositoryCaches } from '../../utils/detectRepository.js'
import { clearResolveGitDirCache } from '../../utils/git/gitFilesystem.js'
import { clearStoredImagePaths } from '../../utils/imageStore.js'
import { clearSessionEnvVars } from '../../utils/sessionEnvVars.js'
/**
* Clear all session-related caches.
* Call this when resuming a session to ensure fresh file/skill discovery.
* This is a subset of what clearConversation does - it only clears caches
* without affecting messages, session ID, or triggering hooks.
*
* @param preservedAgentIds - Agent IDs whose per-agent state should survive
* the clear (e.g., background tasks preserved across /clear). When non-empty,
* agentId-keyed state (invoked skills) is selectively cleared and requestId-keyed
* state (pending permission callbacks, dump state, cache-break tracking) is left
* intact since it cannot be safely scoped to the main session.
*/
export function clearSessionCaches(
preservedAgentIds: ReadonlySet<string> = new Set(),
): void {
const hasPreserved = preservedAgentIds.size > 0
// Clear context caches
getUserContext.cache.clear?.()
getSystemContext.cache.clear?.()
getGitStatus.cache.clear?.()
getSessionStartDate.cache.clear?.()
// Clear file suggestion caches (for @ mentions)
clearFileSuggestionCaches()
// Clear commands/skills cache
clearCommandsCache()
// Clear prompt cache break detection state
if (!hasPreserved) resetPromptCacheBreakDetection()
// Clear system prompt injection (cache breaker)
setSystemPromptInjection(null)
// Clear last emitted date so it's re-detected on next turn
setLastEmittedDate(null)
// Run post-compaction cleanup (clears system prompt sections, microcompact tracking,
// classifier approvals, speculative checks, and — for main-thread compacts — memory
// files cache with load_reason 'compact').
runPostCompactCleanup()
// Reset sent skill names so the skill listing is re-sent after /clear.
// runPostCompactCleanup intentionally does NOT reset this (post-compact
// re-injection costs ~4K tokens), but /clear wipes messages entirely so
// the model needs the full listing again.
resetSentSkillNames()
// Override the memory cache reset with 'session_start': clearSessionCaches is called
// from /clear and --resume/--continue, which are NOT compaction events. Without this,
// the InstructionsLoaded hook would fire with load_reason 'compact' instead of
// 'session_start' on the next getMemoryFiles() call.
resetGetMemoryFilesCache('session_start')
// Clear stored image paths cache
clearStoredImagePaths()
// Clear all session ingress caches (lastUuidMap, sequentialAppendBySession)
clearAllSessions()
// Clear swarm permission pending callbacks
if (!hasPreserved) clearAllPendingCallbacks()
// Clear tungsten session usage tracking
if (process.env.USER_TYPE === 'ant') {
void import('../../tools/TungstenTool/TungstenTool.js').then(
({ clearSessionsWithTungstenUsage, resetInitializationState }) => {
clearSessionsWithTungstenUsage()
resetInitializationState()
},
)
}
// Clear attribution caches (file content cache, pending bash states)
// Dynamic import to preserve dead code elimination for COMMIT_ATTRIBUTION feature flag
if (feature('COMMIT_ATTRIBUTION')) {
void import('../../utils/attributionHooks.js').then(
({ clearAttributionCaches }) => clearAttributionCaches(),
)
}
// Clear repository detection caches
clearRepositoryCaches()
// Clear bash command prefix caches (Haiku-extracted prefixes)
clearCommandPrefixCaches()
// Clear dump prompts state
if (!hasPreserved) clearAllDumpState()
// Clear invoked skills cache (each entry holds full skill file content)
clearInvokedSkills(preservedAgentIds)
// Clear git dir resolution cache
clearResolveGitDirCache()
// Clear dynamic skills (loaded from skill directories)
clearDynamicSkills()
// Clear LSP diagnostic tracking state
resetAllLSPDiagnosticState()
// Clear tracked magic docs
clearTrackedMagicDocs()
// Clear session environment variables
clearSessionEnvVars()
// Clear WebFetch URL cache (up to 50MB of cached page content)
void import('../../tools/WebFetchTool/utils.js').then(
({ clearWebFetchCache }) => clearWebFetchCache(),
)
// Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools)
void import('../../tools/ToolSearchTool/ToolSearchTool.js').then(
({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(),
)
// Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool)
void import('../../tools/AgentTool/loadAgentsDir.js').then(
({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(),
)
// Clear SkillTool prompt cache (accumulates per project root)
void import('../../tools/SkillTool/prompt.js').then(({ clearPromptCache }) =>
clearPromptCache(),
)
}

View File

@@ -0,0 +1,7 @@
import type { LocalCommandCall } from '../../types/command.js'
import { clearConversation } from './conversation.js'
export const call: LocalCommandCall = async (_, context) => {
await clearConversation(context)
return { type: 'text', value: '' }
}

View File

@@ -0,0 +1,251 @@
/**
* Conversation clearing utility.
* This module has heavier dependencies and should be lazy-loaded when possible.
*/
import { feature } from 'bun:bundle'
import { randomUUID, type UUID } from 'crypto'
import {
getLastMainRequestId,
getOriginalCwd,
getSessionId,
regenerateSessionId,
} from '../../bootstrap/state.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import type { AppState } from '../../state/AppState.js'
import { isInProcessTeammateTask } from '../../tasks/InProcessTeammateTask/types.js'
import {
isLocalAgentTask,
type LocalAgentTaskState,
} from '../../tasks/LocalAgentTask/LocalAgentTask.js'
import { isLocalShellTask } from '../../tasks/LocalShellTask/guards.js'
import { asAgentId } from '../../types/ids.js'
import type { Message } from '../../types/message.js'
import { createEmptyAttributionState } from '../../utils/commitAttribution.js'
import type { FileStateCache } from '../../utils/fileStateCache.js'
import {
executeSessionEndHooks,
getSessionEndHookTimeoutMs,
} from '../../utils/hooks.js'
import { logError } from '../../utils/log.js'
import { clearAllPlanSlugs } from '../../utils/plans.js'
import { setCwd } from '../../utils/Shell.js'
import { processSessionStartHooks } from '../../utils/sessionStart.js'
import {
clearSessionMetadata,
getAgentTranscriptPath,
resetSessionFilePointer,
saveWorktreeState,
} from '../../utils/sessionStorage.js'
import {
evictTaskOutput,
initTaskOutputAsSymlink,
} from '../../utils/task/diskOutput.js'
import { getCurrentWorktreeSession } from '../../utils/worktree.js'
import { clearSessionCaches } from './caches.js'
export async function clearConversation({
setMessages,
readFileState,
discoveredSkillNames,
loadedNestedMemoryPaths,
getAppState,
setAppState,
setConversationId,
}: {
setMessages: (updater: (prev: Message[]) => Message[]) => void
readFileState: FileStateCache
discoveredSkillNames?: Set<string>
loadedNestedMemoryPaths?: Set<string>
getAppState?: () => AppState
setAppState?: (f: (prev: AppState) => AppState) => void
setConversationId?: (id: UUID) => void
}): Promise<void> {
// Execute SessionEnd hooks before clearing (bounded by
// CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS, default 1.5s)
const sessionEndTimeoutMs = getSessionEndHookTimeoutMs()
await executeSessionEndHooks('clear', {
getAppState,
setAppState,
signal: AbortSignal.timeout(sessionEndTimeoutMs),
timeoutMs: sessionEndTimeoutMs,
})
// Signal to inference that this conversation's cache can be evicted.
const lastRequestId = getLastMainRequestId()
if (lastRequestId) {
logEvent('tengu_cache_eviction_hint', {
scope:
'conversation_clear' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
last_request_id:
lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
}
// Compute preserved tasks up front so their per-agent state survives the
// cache wipe below. A task is preserved unless it explicitly has
// isBackgrounded === false. Main-session tasks (Ctrl+B) are preserved —
// they write to an isolated per-task transcript and run under an agent
// context, so they're safe across session ID regeneration. See
// LocalMainSessionTask.ts startBackgroundSession.
const preservedAgentIds = new Set<string>()
const preservedLocalAgents: LocalAgentTaskState[] = []
const shouldKillTask = (task: AppState['tasks'][string]): boolean =>
'isBackgrounded' in task && task.isBackgrounded === false
if (getAppState) {
for (const task of Object.values(getAppState().tasks)) {
if (shouldKillTask(task)) continue
if (isLocalAgentTask(task)) {
preservedAgentIds.add(task.agentId)
preservedLocalAgents.push(task)
} else if (isInProcessTeammateTask(task)) {
preservedAgentIds.add(task.identity.agentId)
}
}
}
setMessages(() => [])
// Clear context-blocked flag so proactive ticks resume after /clear
if (feature('PROACTIVE') || feature('KAIROS')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { setContextBlocked } = require('../../proactive/index.js')
/* eslint-enable @typescript-eslint/no-require-imports */
setContextBlocked(false)
}
// Force logo re-render by updating conversationId
if (setConversationId) {
setConversationId(randomUUID())
}
// Clear all session-related caches. Per-agent state for preserved background
// tasks (invoked skills, pending permission callbacks, dump state, cache-break
// tracking) is retained so those agents keep functioning.
clearSessionCaches(preservedAgentIds)
setCwd(getOriginalCwd())
readFileState.clear()
discoveredSkillNames?.clear()
loadedNestedMemoryPaths?.clear()
// Clean out necessary items from App State
if (setAppState) {
setAppState(prev => {
// Partition tasks using the same predicate computed above:
// kill+remove foreground tasks, preserve everything else.
const nextTasks: AppState['tasks'] = {}
for (const [taskId, task] of Object.entries(prev.tasks)) {
if (!shouldKillTask(task)) {
nextTasks[taskId] = task
continue
}
// Foreground task: kill it and drop from state
try {
if (task.status === 'running') {
if (isLocalShellTask(task)) {
task.shellCommand?.kill()
task.shellCommand?.cleanup()
if (task.cleanupTimeoutId) {
clearTimeout(task.cleanupTimeoutId)
}
}
if ('abortController' in task) {
task.abortController?.abort()
}
if ('unregisterCleanup' in task) {
task.unregisterCleanup?.()
}
}
} catch (error) {
logError(error)
}
void evictTaskOutput(taskId)
}
return {
...prev,
tasks: nextTasks,
attribution: createEmptyAttributionState(),
// Clear standalone agent context (name/color set by /rename, /color)
// so the new session doesn't display the old session's identity badge
standaloneAgentContext: undefined,
fileHistory: {
snapshots: [],
trackedFiles: new Set(),
snapshotSequence: 0,
},
// Reset MCP state to default to trigger re-initialization.
// Preserve pluginReconnectKey so /clear doesn't cause a no-op
// (it's only bumped by /reload-plugins).
mcp: {
clients: [],
tools: [],
commands: [],
resources: {},
pluginReconnectKey: prev.mcp.pluginReconnectKey,
},
}
})
}
// Clear plan slug cache so a new plan file is used after /clear
clearAllPlanSlugs()
// Clear cached session metadata (title, tag, agent name/color)
// so the new session doesn't inherit the previous session's identity
clearSessionMetadata()
// Generate new session ID to provide fresh state
// Set the old session as parent for analytics lineage tracking
regenerateSessionId({ setCurrentAsParent: true })
// Update the environment variable so subprocesses use the new session ID
if (process.env.USER_TYPE === 'ant' && process.env.CLAUDE_CODE_SESSION_ID) {
process.env.CLAUDE_CODE_SESSION_ID = getSessionId()
}
await resetSessionFilePointer()
// Preserved local_agent tasks had their TaskOutput symlink baked against the
// old session ID at spawn time, but post-clear transcript writes land under
// the new session directory (appendEntry re-reads getSessionId()). Re-point
// the symlinks so TaskOutput reads the live file instead of a frozen pre-clear
// snapshot. Only re-point running tasks — finished tasks will never write
// again, so re-pointing would replace a valid symlink with a dangling one.
// Main-session tasks use the same per-agent path (they write via
// recordSidechainTranscript to getAgentTranscriptPath), so no special case.
for (const task of preservedLocalAgents) {
if (task.status !== 'running') continue
void initTaskOutputAsSymlink(
task.id,
getAgentTranscriptPath(asAgentId(task.agentId)),
)
}
// Re-persist mode and worktree state after the clear so future --resume
// knows what the new post-clear session was in. clearSessionMetadata
// wiped both from the cache, but the process is still in the same mode
// and (if applicable) the same worktree directory.
if (feature('COORDINATOR_MODE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { saveMode } = require('../../utils/sessionStorage.js')
const {
isCoordinatorMode,
} = require('../../coordinator/coordinatorMode.js')
/* eslint-enable @typescript-eslint/no-require-imports */
saveMode(isCoordinatorMode() ? 'coordinator' : 'normal')
}
const worktreeSession = getCurrentWorktreeSession()
if (worktreeSession) {
saveWorktreeState(worktreeSession)
}
// Execute SessionStart hooks after clearing
const hookMessages = await processSessionStartHooks('clear')
// Update messages with hook results
if (hookMessages.length > 0) {
setMessages(() => hookMessages)
}
}

View File

@@ -0,0 +1,19 @@
/**
* Clear command - minimal metadata only.
* Implementation is lazy-loaded from clear.ts to reduce startup time.
* Utility functions:
* - clearSessionCaches: import from './clear/caches.js'
* - clearConversation: import from './clear/conversation.js'
*/
import type { Command } from '../../commands.js'
const clear = {
type: 'local',
name: 'clear',
description: 'Clear conversation history and free up context',
aliases: ['reset', 'new'],
supportsNonInteractive: false, // Should just create a new session
load: () => import('./clear.js'),
} satisfies Command
export default clear

View File

@@ -0,0 +1,93 @@
import type { UUID } from 'crypto'
import { getSessionId } from '../../bootstrap/state.js'
import type { ToolUseContext } from '../../Tool.js'
import {
AGENT_COLORS,
type AgentColorName,
} from '../../tools/AgentTool/agentColorManager.js'
import type {
LocalJSXCommandContext,
LocalJSXCommandOnDone,
} from '../../types/command.js'
import {
getTranscriptPath,
saveAgentColor,
} from '../../utils/sessionStorage.js'
import { isTeammate } from '../../utils/teammate.js'
const RESET_ALIASES = ['default', 'reset', 'none', 'gray', 'grey'] as const
export async function call(
onDone: LocalJSXCommandOnDone,
context: ToolUseContext & LocalJSXCommandContext,
args: string,
): Promise<null> {
// 队友不能设置自己的颜色
if (isTeammate()) {
onDone(
'Cannot set color: This session is a swarm teammate. Teammate colors are assigned by the team leader.',
{ display: 'system' },
)
return null
}
if (!args || args.trim() === '') {
const colorList = AGENT_COLORS.join(', ')
onDone(`Please provide a color. Available colors: ${colorList}, default`, {
display: 'system',
})
return null
}
const colorArg = args.trim().toLowerCase()
// 处理重置为默认值(灰色)
if (RESET_ALIASES.includes(colorArg as (typeof RESET_ALIASES)[number])) {
const sessionId = getSessionId() as UUID
const fullPath = getTranscriptPath()
// 使用 "default" 标记(不是空字符串)以便 sessionStorage.ts 中的真值检查
// 在会话重启之间保持重置
await saveAgentColor(sessionId, 'default', fullPath)
context.setAppState(prev => ({
...prev,
standaloneAgentContext: {
...prev.standaloneAgentContext,
name: prev.standaloneAgentContext?.name ?? '',
color: undefined,
},
}))
onDone('Session color reset to default', { display: 'system' })
return null
}
if (!AGENT_COLORS.includes(colorArg as AgentColorName)) {
const colorList = AGENT_COLORS.join(', ')
onDone(
`Invalid color "${colorArg}". Available colors: ${colorList}, default`,
{ display: 'system' },
)
return null
}
const sessionId = getSessionId() as UUID
const fullPath = getTranscriptPath()
// 保存到转录文件以在会话之间持久化
await saveAgentColor(sessionId, colorArg, fullPath)
// 更新 AppState 以立即生效
context.setAppState(prev => ({
...prev,
standaloneAgentContext: {
...prev.standaloneAgentContext,
name: prev.standaloneAgentContext?.name ?? '',
color: colorArg as AgentColorName,
},
}))
onDone(`Session color set to: ${colorArg}`, { display: 'system' })
return null
}

View File

@@ -0,0 +1,16 @@
/**
* Color command - minimal metadata only.
* Implementation is lazy-loaded from color.ts to reduce startup time.
*/
import type { Command } from '../../commands.js'
const color = {
type: 'local-jsx',
name: 'color',
description: 'Set the prompt bar color for this session',
immediate: true,
argumentHint: '<color|default>',
load: () => import('./color.js'),
} satisfies Command
export default color

View File

@@ -0,0 +1,92 @@
import type { Command } from '../commands.js'
import { getAttributionTexts } from '../utils/attribution.js'
import { executeShellCommandsInPrompt } from '../utils/promptShellExecution.js'
import { getUndercoverInstructions, isUndercover } from '../utils/undercover.js'
const ALLOWED_TOOLS = [
'Bash(git add:*)',
'Bash(git status:*)',
'Bash(git commit:*)',
]
function getPromptContent(): string {
const { commit: commitAttribution } = getAttributionTexts()
let prefix = ''
if (process.env.USER_TYPE === 'ant' && isUndercover()) {
prefix = getUndercoverInstructions() + '\n'
}
return `${prefix}## Context
- Current git status: !\`git status\`
- Current git diff (staged and unstaged changes): !\`git diff HEAD\`
- Current branch: !\`git branch --show-current\`
- Recent commits: !\`git log --oneline -10\`
## Git Safety Protocol
- NEVER update the git config
- NEVER skip hooks (--no-verify, --no-gpg-sign, etc) unless the user explicitly requests it
- CRITICAL: ALWAYS create NEW commits. NEVER use git commit --amend, unless the user explicitly requests it
- Do not commit files that likely contain secrets (.env, credentials.json, etc). Warn the user if they specifically request to commit those files
- If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit
- Never use git commands with the -i flag (like git rebase -i or git add -i) since they require interactive input which is not supported
## Your task
Based on the above changes, create a single git commit:
1. Analyze all staged changes and draft a commit message:
- Look at the recent commits above to follow this repository's commit message style
- Summarize the nature of the changes (new feature, enhancement, bug fix, refactoring, test, docs, etc.)
- Ensure the message accurately reflects the changes and their purpose (i.e. "add" means a wholly new feature, "update" means an enhancement to an existing feature, "fix" means a bug fix, etc.)
- Draft a concise (1-2 sentences) commit message that focuses on the "why" rather than the "what"
2. Stage relevant files and create the commit using HEREDOC syntax:
\`\`\`
git commit -m "$(cat <<'EOF'
Commit message here.${commitAttribution ? `\n\n${commitAttribution}` : ''}
EOF
)"
\`\`\`
You have the capability to call multiple tools in a single response. Stage and create the commit using a single message. Do not use any other tools or do anything else. Do not send any other text or messages besides these tool calls.`
}
const command = {
type: 'prompt',
name: 'commit',
description: 'Create a git commit',
allowedTools: ALLOWED_TOOLS,
contentLength: 0, // Dynamic content
progressMessage: 'creating commit',
source: 'builtin',
async getPromptForCommand(_args, context) {
const promptContent = getPromptContent()
const finalContent = await executeShellCommandsInPrompt(
promptContent,
{
...context,
getAppState() {
const appState = context.getAppState()
return {
...appState,
toolPermissionContext: {
...appState.toolPermissionContext,
alwaysAllowRules: {
...appState.toolPermissionContext.alwaysAllowRules,
command: ALLOWED_TOOLS,
},
},
}
},
},
'/commit',
)
return [{ type: 'text', text: finalContent }]
},
} satisfies Command
export default command

View File

@@ -0,0 +1,287 @@
import { feature } from 'bun:bundle'
import chalk from 'chalk'
import { markPostCompaction } from 'src/bootstrap/state.js'
import { getSystemPrompt } from '../../constants/prompts.js'
import { getSystemContext, getUserContext } from '../../context.js'
import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'
import { notifyCompaction } from '../../services/api/promptCacheBreakDetection.js'
import {
type CompactionResult,
compactConversation,
ERROR_MESSAGE_INCOMPLETE_RESPONSE,
ERROR_MESSAGE_NOT_ENOUGH_MESSAGES,
ERROR_MESSAGE_USER_ABORT,
mergeHookInstructions,
} from '../../services/compact/compact.js'
import { suppressCompactWarning } from '../../services/compact/compactWarningState.js'
import { microcompactMessages } from '../../services/compact/microCompact.js'
import { runPostCompactCleanup } from '../../services/compact/postCompactCleanup.js'
import { trySessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'
import { setLastSummarizedMessageId } from '../../services/SessionMemory/sessionMemoryUtils.js'
import type { ToolUseContext } from '../../Tool.js'
import type { LocalCommandCall } from '../../types/command.js'
import type { Message } from '../../types/message.js'
import { hasExactErrorMessage } from '../../utils/errors.js'
import { executePreCompactHooks } from '../../utils/hooks.js'
import { logError } from '../../utils/log.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'
import {
buildEffectiveSystemPrompt,
type SystemPrompt,
} from '../../utils/systemPrompt.js'
/* eslint-disable @typescript-eslint/no-require-imports */
const reactiveCompact = feature('REACTIVE_COMPACT')
? (require('../../services/compact/reactiveCompact.js') as typeof import('../../services/compact/reactiveCompact.js'))
: null
/* eslint-enable @typescript-eslint/no-require-imports */
export const call: LocalCommandCall = async (args, context) => {
const { abortController } = context
let { messages } = context
// REPL keeps snipped messages for UI scrollback — project so the compact
// model doesn't summarize content that was intentionally removed.
messages = getMessagesAfterCompactBoundary(messages)
if (messages.length === 0) {
throw new Error('No messages to compact')
}
const customInstructions = args.trim()
try {
// 如果没有自定义指令,首先尝试会话内存压缩
//(会话内存压缩不支持自定义指令)
if (!customInstructions) {
const sessionMemoryResult = await trySessionMemoryCompaction(
messages,
context.agentId,
)
if (sessionMemoryResult) {
getUserContext.cache.clear?.()
runPostCompactCleanup()
// 重置缓存读取基准,以便压缩后的下降不会被标记为
// 中断。compactConversation 在内部执行此操作SM-compact 不执行。
if (feature('PROMPT_CACHE_BREAK_DETECTION')) {
notifyCompaction(
context.options.querySource ?? 'compact',
context.agentId,
)
}
markPostCompaction()
// 成功压缩后立即抑制警告
suppressCompactWarning()
return {
type: 'compact',
compactionResult: sessionMemoryResult,
displayText: buildDisplayText(context),
}
}
}
// 仅响应模式:通过响应路径路由 /compact。
// 在会话内存(该路径便宜且正交)之后检查。
if (reactiveCompact?.isReactiveOnlyMode()) {
return await compactViaReactive(
messages,
context,
customInstructions,
reactiveCompact,
)
}
// 回退到传统压缩
// 首先运行 microcompact 以在总结前减少令牌
const microcompactResult = await microcompactMessages(messages, context)
const messagesForCompact = microcompactResult.messages
const result = await compactConversation(
messagesForCompact,
context,
await getCacheSharingParams(context, messagesForCompact),
false,
customInstructions,
false,
)
// 重置 lastSummarizedMessageId因为传统压缩替换所有消息
// 且旧消息 UUID 在新消息数组中不再存在
setLastSummarizedMessageId(undefined)
// 成功压缩后抑制"剩余上下文直到自动压缩"警告
suppressCompactWarning()
getUserContext.cache.clear?.()
runPostCompactCleanup()
return {
type: 'compact',
compactionResult: result,
displayText: buildDisplayText(context, result.userDisplayMessage),
}
} catch (error) {
if (abortController.signal.aborted) {
throw new Error('Compaction canceled.')
} else if (hasExactErrorMessage(error, ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)) {
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
} else if (hasExactErrorMessage(error, ERROR_MESSAGE_INCOMPLETE_RESPONSE)) {
throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
} else {
logError(error)
throw new Error(`Error during compaction: ${error}`)
}
}
}
async function compactViaReactive(
messages: Message[],
context: ToolUseContext,
customInstructions: string,
reactive: NonNullable<typeof reactiveCompact>,
): Promise<{
type: 'compact'
compactionResult: CompactionResult
displayText: string
}> {
context.onCompactProgress?.({
type: 'hooks_start',
hookType: 'pre_compact',
})
context.setSDKStatus?.('compacting')
try {
// Hooks 和 cache-param 构建是独立的 — 并发运行。
// getCacheSharingParams 遍历所有工具以构建系统提示;
// pre-compact hooks 生成子进程。两者不相互依赖。
const [hookResult, cacheSafeParams] = await Promise.all([
executePreCompactHooks(
{ trigger: 'manual', customInstructions: customInstructions || null },
context.abortController.signal,
),
getCacheSharingParams(context, messages),
])
const mergedInstructions = mergeHookInstructions(
customInstructions,
hookResult.newCustomInstructions,
)
context.setStreamMode?.('requesting')
context.setResponseLength?.(() => 0)
context.onCompactProgress?.({ type: 'compact_start' })
const outcome = await reactive.reactiveCompactOnPromptTooLong(
messages,
cacheSafeParams,
{ customInstructions: mergedInstructions, trigger: 'manual' },
)
if (!outcome.ok) {
// 外部 catch 在 `call` 中转换这些aborted → "Compaction
// canceled."(通过 abortController.signal.aborted 检查NOT_ENOUGH →
// 按原样重新抛出,其他 → "Error during compaction: …"。
switch (outcome.reason) {
case 'too_few_groups':
throw new Error(ERROR_MESSAGE_NOT_ENOUGH_MESSAGES)
case 'aborted':
throw new Error(ERROR_MESSAGE_USER_ABORT)
case 'exhausted':
case 'error':
case 'media_unstrippable':
throw new Error(ERROR_MESSAGE_INCOMPLETE_RESPONSE)
}
}
// 反映 tryReactiveCompact 中的成功后期清理,减去
// resetMicrocompactState — processSlashCommand 为所有
// type:'compact' 结果调用它。
setLastSummarizedMessageId(undefined)
runPostCompactCleanup()
suppressCompactWarning()
getUserContext.cache.clear?.()
// reactiveCompactOnPromptTooLong 运行 PostCompact hooks 但不运行 PreCompact
// — 两个调用者(这里和 tryReactiveCompact在外部运行 PreCompact 以便
// 它们可以将 PreCompact 的 userDisplayMessage 与 PostCompact 的合并到这里。
// 此调用者另外与 getCacheSharingParams 并发运行它。
const combinedMessage =
[hookResult.userDisplayMessage, outcome.result.userDisplayMessage]
.filter(Boolean)
.join('\n') || undefined
return {
type: 'compact',
compactionResult: {
...outcome.result,
userDisplayMessage: combinedMessage,
},
displayText: buildDisplayText(context, combinedMessage),
}
} finally {
context.setStreamMode?.('requesting')
context.setResponseLength?.(() => 0)
context.onCompactProgress?.({ type: 'compact_end' })
context.setSDKStatus?.(null)
}
}
function buildDisplayText(
context: ToolUseContext,
userDisplayMessage?: string,
): string {
const upgradeMessage = getUpgradeMessage('tip')
const expandShortcut = getShortcutDisplay(
'app:toggleTranscript',
'Global',
'ctrl+o',
)
const dimmed = [
...(context.options.verbose
? []
: [`(${expandShortcut} to see full summary)`]),
...(userDisplayMessage ? [userDisplayMessage] : []),
...(upgradeMessage ? [upgradeMessage] : []),
]
return chalk.dim('Compacted ' + dimmed.join('\n'))
}
async function getCacheSharingParams(
context: ToolUseContext,
forkContextMessages: Message[],
): Promise<{
systemPrompt: SystemPrompt
userContext: { [k: string]: string }
systemContext: { [k: string]: string }
toolUseContext: ToolUseContext
forkContextMessages: Message[]
}> {
const appState = context.getAppState()
const defaultSysPrompt = await getSystemPrompt(
context.options.tools,
context.options.mainLoopModel,
Array.from(
appState.toolPermissionContext.additionalWorkingDirectories.keys(),
),
context.options.mcpClients,
)
const systemPrompt = buildEffectiveSystemPrompt({
mainThreadAgentDefinition: undefined,
toolUseContext: context,
customSystemPrompt: context.options.customSystemPrompt,
defaultSystemPrompt: defaultSysPrompt,
appendSystemPrompt: context.options.appendSystemPrompt,
})
const [userContext, systemContext] = await Promise.all([
getUserContext(),
getSystemContext(),
])
return {
systemPrompt,
userContext,
systemContext,
toolUseContext: context,
forkContextMessages,
}
}

View File

@@ -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

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const config = {
aliases: ['settings'],
type: 'local-jsx',
name: 'config',
description: 'Open config panel',
load: () => import('./config.js'),
} satisfies Command
export default config

View File

@@ -0,0 +1,325 @@
import { feature } from 'bun:bundle'
import { microcompactMessages } from '../../services/compact/microCompact.js'
import type { AppState } from '../../state/AppStateStore.js'
import type { Tools, ToolUseContext } from '../../Tool.js'
import type { AgentDefinitionsResult } from '../../tools/AgentTool/loadAgentsDir.js'
import type { Message } from '../../types/message.js'
import {
analyzeContextUsage,
type ContextData,
} from '../../utils/analyzeContext.js'
import { formatTokens } from '../../utils/format.js'
import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'
import { getSourceDisplayName } from '../../utils/settings/constants.js'
import { plural } from '../../utils/stringUtils.js'
/**
* `/context`(斜杠命令)和 SDK `get_context_usage` 控制请求
* 的共享数据收集路径。反映 query.ts 的 API 前转换
*压缩边界、projectView、microcompact以便令牌计数反映
* 模型实际看到的内容。
*/
type CollectContextDataInput = {
messages: Message[]
getAppState: () => AppState
options: {
mainLoopModel: string
tools: Tools
agentDefinitions: AgentDefinitionsResult
customSystemPrompt?: string
appendSystemPrompt?: string
}
}
export async function collectContextData(
context: CollectContextDataInput,
): Promise<ContextData> {
const {
messages,
getAppState,
options: {
mainLoopModel,
tools,
agentDefinitions,
customSystemPrompt,
appendSystemPrompt,
},
} = context
let apiView = getMessagesAfterCompactBoundary(messages)
if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { projectView } =
require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js')
/* eslint-enable @typescript-eslint/no-require-imports */
apiView = projectView(apiView)
}
const { messages: compactedMessages } = await microcompactMessages(apiView)
const appState = getAppState()
return analyzeContextUsage(
compactedMessages,
mainLoopModel,
async () => appState.toolPermissionContext,
tools,
agentDefinitions,
undefined, // terminalWidth
// analyzeContextUsage only reads options.{customSystemPrompt,appendSystemPrompt}
// but its signature declares the full Pick<ToolUseContext, 'options'>.
{ options: { customSystemPrompt, appendSystemPrompt } } as Pick<
ToolUseContext,
'options'
>,
undefined, // mainThreadAgentDefinition
apiView, // original messages for API usage extraction
)
}
export async function call(
_args: string,
context: ToolUseContext,
): Promise<{ type: 'text'; value: string }> {
const data = await collectContextData(context)
return {
type: 'text' as const,
value: formatContextAsMarkdownTable(data),
}
}
function formatContextAsMarkdownTable(data: ContextData): string {
const {
categories,
totalTokens,
rawMaxTokens,
percentage,
model,
memoryFiles,
mcpTools,
agents,
skills,
messageBreakdown,
systemTools,
systemPromptSections,
} = data
let output = `## Context Usage\n\n`
output += `**Model:** ${model} \n`
output += `**Tokens:** ${formatTokens(totalTokens)} / ${formatTokens(rawMaxTokens)} (${percentage}%)\n`
// Context-collapse status. Always show when the runtime gate is on —
// the user needs to know which strategy is managing their context
// even before anything has fired.
if (feature('CONTEXT_COLLAPSE')) {
/* eslint-disable @typescript-eslint/no-require-imports */
const { getStats, isContextCollapseEnabled } =
require('../../services/contextCollapse/index.js') as typeof import('../../services/contextCollapse/index.js')
/* eslint-enable @typescript-eslint/no-require-imports */
if (isContextCollapseEnabled()) {
const s = getStats()
const { health: h } = s
const parts = []
if (s.collapsedSpans > 0) {
parts.push(
`${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} messages)`,
)
}
if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`)
const summary =
parts.length > 0
? parts.join(', ')
: h.totalSpawns > 0
? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet`
: 'waiting for first trigger'
output += `**Context strategy:** collapse (${summary})\n`
if (h.totalErrors > 0) {
output += `**Collapse errors:** ${h.totalErrors}/${h.totalSpawns} spawns failed`
if (h.lastError) {
output += ` (last: ${h.lastError.slice(0, 80)})`
}
output += '\n'
} else if (h.emptySpawnWarningEmitted) {
output += `**Collapse idle:** ${h.totalEmptySpawns} consecutive empty runs\n`
}
}
}
output += '\n'
// Main categories table
const visibleCategories = categories.filter(
cat =>
cat.tokens > 0 &&
cat.name !== 'Free space' &&
cat.name !== 'Autocompact buffer',
)
if (visibleCategories.length > 0) {
output += `### Estimated usage by category\n\n`
output += `| Category | Tokens | Percentage |\n`
output += `|----------|--------|------------|\n`
for (const cat of visibleCategories) {
const percentDisplay = ((cat.tokens / rawMaxTokens) * 100).toFixed(1)
output += `| ${cat.name} | ${formatTokens(cat.tokens)} | ${percentDisplay}% |\n`
}
const freeSpaceCategory = categories.find(c => c.name === 'Free space')
if (freeSpaceCategory && freeSpaceCategory.tokens > 0) {
const percentDisplay = (
(freeSpaceCategory.tokens / rawMaxTokens) *
100
).toFixed(1)
output += `| Free space | ${formatTokens(freeSpaceCategory.tokens)} | ${percentDisplay}% |\n`
}
const autocompactCategory = categories.find(
c => c.name === 'Autocompact buffer',
)
if (autocompactCategory && autocompactCategory.tokens > 0) {
const percentDisplay = (
(autocompactCategory.tokens / rawMaxTokens) *
100
).toFixed(1)
output += `| Autocompact buffer | ${formatTokens(autocompactCategory.tokens)} | ${percentDisplay}% |\n`
}
output += `\n`
}
// MCP tools
if (mcpTools.length > 0) {
output += `### MCP Tools\n\n`
output += `| Tool | Server | Tokens |\n`
output += `|------|--------|--------|\n`
for (const tool of mcpTools) {
output += `| ${tool.name} | ${tool.serverName} | ${formatTokens(tool.tokens)} |\n`
}
output += `\n`
}
// System tools (ant-only)
if (
systemTools &&
systemTools.length > 0 &&
process.env.USER_TYPE === 'ant'
) {
output += `### [ANT-ONLY] System Tools\n\n`
output += `| Tool | Tokens |\n`
output += `|------|--------|\n`
for (const tool of systemTools) {
output += `| ${tool.name} | ${formatTokens(tool.tokens)} |\n`
}
output += `\n`
}
// System prompt sections (ant-only)
if (
systemPromptSections &&
systemPromptSections.length > 0 &&
process.env.USER_TYPE === 'ant'
) {
output += `### [ANT-ONLY] System Prompt Sections\n\n`
output += `| Section | Tokens |\n`
output += `|---------|--------|\n`
for (const section of systemPromptSections) {
output += `| ${section.name} | ${formatTokens(section.tokens)} |\n`
}
output += `\n`
}
// Custom agents
if (agents.length > 0) {
output += `### Custom Agents\n\n`
output += `| Agent Type | Source | Tokens |\n`
output += `|------------|--------|--------|\n`
for (const agent of agents) {
let sourceDisplay: string
switch (agent.source) {
case 'projectSettings':
sourceDisplay = 'Project'
break
case 'userSettings':
sourceDisplay = 'User'
break
case 'localSettings':
sourceDisplay = 'Local'
break
case 'flagSettings':
sourceDisplay = 'Flag'
break
case 'policySettings':
sourceDisplay = 'Policy'
break
case 'plugin':
sourceDisplay = 'Plugin'
break
case 'built-in':
sourceDisplay = 'Built-in'
break
default:
sourceDisplay = String(agent.source)
}
output += `| ${agent.agentType} | ${sourceDisplay} | ${formatTokens(agent.tokens)} |\n`
}
output += `\n`
}
// Memory files
if (memoryFiles.length > 0) {
output += `### Memory Files\n\n`
output += `| Type | Path | Tokens |\n`
output += `|------|------|--------|\n`
for (const file of memoryFiles) {
output += `| ${file.type} | ${file.path} | ${formatTokens(file.tokens)} |\n`
}
output += `\n`
}
// Skills
if (skills && skills.tokens > 0 && skills.skillFrontmatter.length > 0) {
output += `### Skills\n\n`
output += `| Skill | Source | Tokens |\n`
output += `|-------|--------|--------|\n`
for (const skill of skills.skillFrontmatter) {
output += `| ${skill.name} | ${getSourceDisplayName(skill.source)} | ${formatTokens(skill.tokens)} |\n`
}
output += `\n`
}
// Message breakdown (ant-only)
if (messageBreakdown && process.env.USER_TYPE === 'ant') {
output += `### [ANT-ONLY] Message Breakdown\n\n`
output += `| Category | Tokens |\n`
output += `|----------|--------|\n`
output += `| Tool calls | ${formatTokens(messageBreakdown.toolCallTokens)} |\n`
output += `| Tool results | ${formatTokens(messageBreakdown.toolResultTokens)} |\n`
output += `| Attachments | ${formatTokens(messageBreakdown.attachmentTokens)} |\n`
output += `| Assistant messages (non-tool) | ${formatTokens(messageBreakdown.assistantMessageTokens)} |\n`
output += `| User messages (non-tool-result) | ${formatTokens(messageBreakdown.userMessageTokens)} |\n`
output += `\n`
if (messageBreakdown.toolCallsByType.length > 0) {
output += `#### Top Tools\n\n`
output += `| Tool | Call Tokens | Result Tokens |\n`
output += `|------|-------------|---------------|\n`
for (const tool of messageBreakdown.toolCallsByType) {
output += `| ${tool.name} | ${formatTokens(tool.callTokens)} | ${formatTokens(tool.resultTokens)} |\n`
}
output += `\n`
}
if (messageBreakdown.attachmentsByType.length > 0) {
output += `#### Top Attachments\n\n`
output += `| Attachment | Tokens |\n`
output += `|------------|--------|\n`
for (const attachment of messageBreakdown.attachmentsByType) {
output += `| ${attachment.name} | ${formatTokens(attachment.tokens)} |\n`
}
output += `\n`
}
}
return output
}

View File

@@ -0,0 +1,24 @@
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import type { Command } from '../../commands.js'
export const context: Command = {
name: 'context',
description: 'Visualize current context usage as a colored grid',
isEnabled: () => !getIsNonInteractiveSession(),
type: 'local-jsx',
load: () => import('./context.js'),
}
export const contextNonInteractive: Command = {
type: 'local',
name: 'context',
supportsNonInteractive: true,
description: 'Show current context usage',
get isHidden() {
return !getIsNonInteractiveSession()
},
isEnabled() {
return getIsNonInteractiveSession()
},
load: () => import('./context-noninteractive.js'),
}

View File

@@ -0,0 +1,15 @@
/**
* Copy command - minimal metadata only.
* Implementation is lazy-loaded from copy.tsx to reduce startup time.
*/
import type { Command } from '../../commands.js'
const copy = {
type: 'local-jsx',
name: 'copy',
description:
"Copy Claude's last response to clipboard (or /copy N for the Nth-latest)",
load: () => import('./copy.js'),
} satisfies Command
export default copy

View File

@@ -0,0 +1,24 @@
import { formatTotalCost } from '../../cost-tracker.js'
import { currentLimits } from '../../services/claudeAiLimits.js'
import type { LocalCommandCall } from '../../types/command.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'
export const call: LocalCommandCall = async () => {
if (isClaudeAISubscriber()) {
let value: string
if (currentLimits.isUsingOverage) {
value =
'You are currently using your overages to power your Claude Code usage. We will automatically switch you back to your subscription rate limits when they reset'
} else {
value =
'You are currently using your subscription to power your Claude Code usage'
}
if (process.env.USER_TYPE === 'ant') {
value += `\n\n[ANT-ONLY] Showing cost anyway:\n ${formatTotalCost()}`
}
return { type: 'text', value }
}
return { type: 'text', value: formatTotalCost() }
}

View File

@@ -0,0 +1,23 @@
/**
* Cost command - minimal metadata only.
* Implementation is lazy-loaded from cost.ts to reduce startup time.
*/
import type { Command } from '../../commands.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'
const cost = {
type: 'local',
name: 'cost',
description: 'Show the total cost and duration of the current session',
get isHidden() {
// Keep visible for Ants even if they're subscribers (they see cost breakdowns)
if (process.env.USER_TYPE === 'ant') {
return false
}
return isClaudeAISubscriber()
},
supportsNonInteractive: true,
load: () => import('./cost.js'),
} satisfies Command
export default cost

View File

@@ -0,0 +1,26 @@
import type { Command } from '../../commands.js'
function isSupportedPlatform(): boolean {
if (process.platform === 'darwin') {
return true
}
if (process.platform === 'win32' && process.arch === 'x64') {
return true
}
return false
}
const desktop = {
type: 'local-jsx',
name: 'desktop',
aliases: ['app'],
description: 'Continue the current session in Claude Desktop',
availability: ['claude-ai'],
isEnabled: isSupportedPlatform,
get isHidden() {
return !isSupportedPlatform()
},
load: () => import('./desktop.js'),
} satisfies Command
export default desktop

View File

@@ -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

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
const doctor: Command = {
name: 'doctor',
description: 'Diagnose and verify your Claude Code installation and settings',
isEnabled: () => !isEnvTruthy(process.env.DISABLE_DOCTOR_COMMAND),
type: 'local-jsx',
load: () => import('./doctor.js'),
}
export default doctor

View File

@@ -0,0 +1,13 @@
import type { Command } from '../../commands.js'
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
export default {
type: 'local-jsx',
name: 'effort',
description: 'Set effort level for model usage',
argumentHint: '[low|medium|high|max|auto]',
get immediate() {
return shouldInferenceConfigCommandBeImmediate()
},
load: () => import('./effort.js'),
} satisfies Command

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const exit = {
type: 'local-jsx',
name: 'exit',
aliases: ['quit'],
description: 'Exit the REPL',
immediate: true,
load: () => import('./exit.js'),
} satisfies Command
export default exit

View File

@@ -0,0 +1,118 @@
import {
checkAdminRequestEligibility,
createAdminRequest,
getMyAdminRequests,
} from '../../services/api/adminRequests.js'
import { invalidateOverageCreditGrantCache } from '../../services/api/overageCreditGrant.js'
import { type ExtraUsage, fetchUtilization } from '../../services/api/usage.js'
import { getSubscriptionType } from '../../utils/auth.js'
import { hasClaudeAiBillingAccess } from '../../utils/billing.js'
import { openBrowser } from '../../utils/browser.js'
import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'
import { logError } from '../../utils/log.js'
type ExtraUsageResult =
| { type: 'message'; value: string }
| { type: 'browser-opened'; url: string; opened: boolean }
export async function runExtraUsage(): Promise<ExtraUsageResult> {
if (!getGlobalConfig().hasVisitedExtraUsage) {
saveGlobalConfig(prev => ({ ...prev, hasVisitedExtraUsage: true }))
}
// Invalidate only the current org's entry so a follow-up read refetches
// the granted state. Separate from the visited flag since users may run
// /extra-usage more than once while iterating on the claim flow.
invalidateOverageCreditGrantCache()
const subscriptionType = getSubscriptionType()
const isTeamOrEnterprise =
subscriptionType === 'team' || subscriptionType === 'enterprise'
const hasBillingAccess = hasClaudeAiBillingAccess()
if (!hasBillingAccess && isTeamOrEnterprise) {
// Mirror apps/claude-ai useHasUnlimitedOverage(): if overage is enabled
// with no monthly cap, there is nothing to request. On fetch error, fall
// through and let the user ask (matching web's "err toward show" behavior).
let extraUsage: ExtraUsage | null | undefined
try {
const utilization = await fetchUtilization()
extraUsage = utilization?.extra_usage
} catch (error) {
logError(error as Error)
}
if (extraUsage?.is_enabled && extraUsage.monthly_limit === null) {
return {
type: 'message',
value:
'Your organization already has unlimited extra usage. No request needed.',
}
}
try {
const eligibility = await checkAdminRequestEligibility('limit_increase')
if (eligibility?.is_allowed === false) {
return {
type: 'message',
value: 'Please contact your admin to manage extra usage settings.',
}
}
} catch (error) {
logError(error as Error)
// If eligibility check fails, continue — the create endpoint will enforce if necessary
}
try {
const pendingOrDismissedRequests = await getMyAdminRequests(
'limit_increase',
['pending', 'dismissed'],
)
if (pendingOrDismissedRequests && pendingOrDismissedRequests.length > 0) {
return {
type: 'message',
value:
'You have already submitted a request for extra usage to your admin.',
}
}
} catch (error) {
logError(error as Error)
// Fall through to creating a new request below
}
try {
await createAdminRequest({
request_type: 'limit_increase',
details: null,
})
return {
type: 'message',
value: extraUsage?.is_enabled
? 'Request sent to your admin to increase extra usage.'
: 'Request sent to your admin to enable extra usage.',
}
} catch (error) {
logError(error as Error)
// Fall through to generic message below
}
return {
type: 'message',
value: 'Please contact your admin to manage extra usage settings.',
}
}
const url = isTeamOrEnterprise
? 'https://claude.ai/admin-settings/usage'
: 'https://claude.ai/settings/usage'
try {
const opened = await openBrowser(url)
return { type: 'browser-opened', url, opened }
} catch (error) {
logError(error as Error)
return {
type: 'message',
value: `Failed to open browser. Please visit ${url} to manage extra usage.`,
}
}
}

View File

@@ -0,0 +1,16 @@
import { runExtraUsage } from './extra-usage-core.js'
export async function call(): Promise<{ type: 'text'; value: string }> {
const result = await runExtraUsage()
if (result.type === 'message') {
return { type: 'text', value: result.value }
}
return {
type: 'text',
value: result.opened
? `Browser opened to manage extra usage. If it didn't open, visit: ${result.url}`
: `Please visit ${result.url} to manage extra usage.`,
}
}

View File

@@ -0,0 +1,31 @@
import { getIsNonInteractiveSession } from '../../bootstrap/state.js'
import type { Command } from '../../commands.js'
import { isOverageProvisioningAllowed } from '../../utils/auth.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
function isExtraUsageAllowed(): boolean {
if (isEnvTruthy(process.env.DISABLE_EXTRA_USAGE_COMMAND)) {
return false
}
return isOverageProvisioningAllowed()
}
export const extraUsage = {
type: 'local-jsx',
name: 'extra-usage',
description: 'Configure extra usage to keep working when limits are hit',
isEnabled: () => isExtraUsageAllowed() && !getIsNonInteractiveSession(),
load: () => import('./extra-usage.js'),
} satisfies Command
export const extraUsageNonInteractive = {
type: 'local',
name: 'extra-usage',
supportsNonInteractive: true,
description: 'Configure extra usage to keep working when limits are hit',
isEnabled: () => isExtraUsageAllowed() && getIsNonInteractiveSession(),
get isHidden() {
return !getIsNonInteractiveSession()
},
load: () => import('./extra-usage-noninteractive.js'),
} satisfies Command

View File

@@ -0,0 +1,26 @@
import type { Command } from '../../commands.js'
import {
FAST_MODE_MODEL_DISPLAY,
isFastModeEnabled,
} from '../../utils/fastMode.js'
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
const fast = {
type: 'local-jsx',
name: 'fast',
get description() {
return `Toggle fast mode (${FAST_MODE_MODEL_DISPLAY} only)`
},
availability: ['claude-ai', 'console'],
isEnabled: () => isFastModeEnabled(),
get isHidden() {
return !isFastModeEnabled()
},
argumentHint: '[on|off]',
get immediate() {
return shouldInferenceConfigCommandBeImmediate()
},
load: () => import('./fast.js'),
} satisfies Command
export default fast

View File

@@ -0,0 +1,26 @@
import type { Command } from '../../commands.js'
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { isEssentialTrafficOnly } from '../../utils/privacyLevel.js'
const feedback = {
aliases: ['bug'],
type: 'local-jsx',
name: 'feedback',
description: `Submit feedback about Claude Code`,
argumentHint: '[report]',
isEnabled: () =>
!(
isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) ||
isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) ||
isEnvTruthy(process.env.DISABLE_FEEDBACK_COMMAND) ||
isEnvTruthy(process.env.DISABLE_BUG_COMMAND) ||
isEssentialTrafficOnly() ||
process.env.USER_TYPE === 'ant' ||
!isPolicyAllowed('allow_product_feedback')
),
load: () => import('./feedback.js'),
} satisfies Command
export default feedback

View File

@@ -0,0 +1,19 @@
import { relative } from 'path'
import type { ToolUseContext } from '../../Tool.js'
import type { LocalCommandResult } from '../../types/command.js'
import { getCwd } from '../../utils/cwd.js'
import { cacheKeys } from '../../utils/fileStateCache.js'
export async function call(
_args: string,
context: ToolUseContext,
): Promise<LocalCommandResult> {
const files = context.readFileState ? cacheKeys(context.readFileState) : []
if (files.length === 0) {
return { type: 'text' as const, value: 'No files in context' }
}
const fileList = files.map(file => relative(getCwd(), file)).join('\n')
return { type: 'text' as const, value: `Files in context:\n${fileList}` }
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const files = {
type: 'local',
name: 'files',
description: 'List all files currently in context',
isEnabled: () => process.env.USER_TYPE === 'ant',
supportsNonInteractive: true,
load: () => import('./files.js'),
} satisfies Command
export default files

View File

@@ -0,0 +1,17 @@
import { performHeapDump } from '../../utils/heapDumpService.js'
export async function call(): Promise<{ type: 'text'; value: string }> {
const result = await performHeapDump()
if (!result.success) {
return {
type: 'text',
value: `Failed to create heap dump: ${result.error}`,
}
}
return {
type: 'text',
value: `${result.heapPath}\n${result.diagPath}`,
}
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const heapDump = {
type: 'local',
name: 'heapdump',
description: 'Dump the JS heap to ~/Desktop',
isHidden: true,
supportsNonInteractive: true,
load: () => import('./heapdump.js'),
} satisfies Command
export default heapDump

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const hooks = {
type: 'local-jsx',
name: 'hooks',
description: 'View hook configurations for tool events',
immediate: true,
load: () => import('./hooks.js'),
} satisfies Command
export default hooks

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const ide = {
type: 'local-jsx',
name: 'ide',
description: 'Manage IDE integrations and show status',
argumentHint: '[open]',
load: () => import('./ide.js'),
} satisfies Command
export default ide

View File

@@ -0,0 +1,262 @@
import type { Command } from '../commands.js'
const command = {
type: 'prompt',
name: 'init-verifiers',
description:
'Create verifier skill(s) for automated verification of code changes',
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your project and creating verifier skills',
source: 'builtin',
async getPromptForCommand() {
return [
{
type: 'text',
text: `Use the TodoWrite tool to track your progress through this multi-step task.
## Goal
Create one or more verifier skills that can be used by the Verify agent to automatically verify code changes in this project or folder. You may create multiple verifiers if the project has different verification needs (e.g., both web UI and API endpoints).
**Do NOT create verifiers for unit tests or typechecking.** Those are already handled by the standard build/test workflow and don't need dedicated verifier skills. Focus on functional verification: web UI (Playwright), CLI (Tmux), and API (HTTP) verifiers.
## Phase 1: Auto-Detection
Analyze the project to detect what's in different subdirectories. The project may contain multiple sub-projects or areas that need different verification approaches (e.g., a web frontend, an API backend, and shared libraries all in one repo).
1. **Scan top-level directories** to identify distinct project areas:
- Look for separate package.json, Cargo.toml, pyproject.toml, go.mod in subdirectories
- Identify distinct application types in different folders
2. **For each area, detect:**
a. **Project type and stack**
- Primary language(s) and frameworks
- Package managers (npm, yarn, pnpm, pip, cargo, etc.)
b. **Application type**
- Web app (React, Next.js, Vue, etc.) → suggest Playwright-based verifier
- CLI tool → suggest Tmux-based verifier
- API service (Express, FastAPI, etc.) → suggest HTTP-based verifier
c. **Existing verification tools**
- Test frameworks (Jest, Vitest, pytest, etc.)
- E2E tools (Playwright, Cypress, etc.)
- Dev server scripts in package.json
d. **Dev server configuration**
- How to start the dev server
- What URL it runs on
- What text indicates it's ready
3. **Installed verification packages** (for web apps)
- Check if Playwright is installed (look in package.json dependencies/devDependencies)
- Check MCP configuration (.mcp.json) for browser automation tools:
- Playwright MCP server
- Chrome DevTools MCP server
- Claude Chrome Extension MCP (browser-use via Claude's Chrome extension)
- For Python projects, check for playwright, pytest-playwright
## Phase 2: Verification Tool Setup
Based on what was detected in Phase 1, help the user set up appropriate verification tools.
### For Web Applications
1. **If browser automation tools are already installed/configured**, ask the user which one they want to use:
- Use AskUserQuestion to present the detected options
- Example: "I found Playwright and Chrome DevTools MCP configured. Which would you like to use for verification?"
2. **If NO browser automation tools are detected**, ask if they want to install/configure one:
- Use AskUserQuestion: "No browser automation tools detected. Would you like to set one up for UI verification?"
- Options to offer:
- **Playwright** (Recommended) - Full browser automation library, works headless, great for CI
- **Chrome DevTools MCP** - Uses Chrome DevTools Protocol via MCP
- **Claude Chrome Extension** - Uses the Claude Chrome extension for browser interaction (requires the extension installed in Chrome)
- **None** - Skip browser automation (will use basic HTTP checks only)
3. **If user chooses to install Playwright**, run the appropriate command based on package manager:
- For npm: \`npm install -D @playwright/test && npx playwright install\`
- For yarn: \`yarn add -D @playwright/test && yarn playwright install\`
- For pnpm: \`pnpm add -D @playwright/test && pnpm exec playwright install\`
- For bun: \`bun add -D @playwright/test && bun playwright install\`
4. **If user chooses Chrome DevTools MCP or Claude Chrome Extension**:
- These require MCP server configuration rather than package installation
- Ask if they want you to add the MCP server configuration to .mcp.json
- For Claude Chrome Extension, inform them they need the extension installed from the Chrome Web Store
5. **MCP Server Setup** (if applicable):
- If user selected an MCP-based option, configure the appropriate entry in .mcp.json
- Update the verifier skill's allowed-tools to use the appropriate mcp__* tools
### For CLI Tools
1. Check if asciinema is available (run \`which asciinema\`)
2. If not available, inform the user that asciinema can help record verification sessions but is optional
3. Tmux is typically system-installed, just verify it's available
### For API Services
1. Check if HTTP testing tools are available:
- curl (usually system-installed)
- httpie (\`http\` command)
2. No installation typically needed
## Phase 3: Interactive Q&A
Based on the areas detected in Phase 1, you may need to create multiple verifiers. For each distinct area, use the AskUserQuestion tool to confirm:
1. **Verifier name** - Based on detection, suggest a name but let user choose:
If there is only ONE project area, use the simple format:
- "verifier-playwright" for web UI testing
- "verifier-cli" for CLI/terminal testing
- "verifier-api" for HTTP API testing
If there are MULTIPLE project areas, use the format \`verifier-<project>-<type>\`:
- "verifier-frontend-playwright" for the frontend web UI
- "verifier-backend-api" for the backend API
- "verifier-admin-playwright" for an admin dashboard
The \`<project>\` portion should be a short identifier for the subdirectory or project area (e.g., the folder name or package name).
Custom names are allowed but MUST include "verifier" in the name — the Verify agent discovers skills by looking for "verifier" in the folder name.
2. **Project-specific questions** based on type:
For web apps (playwright):
- Dev server command (e.g., "npm run dev")
- Dev server URL (e.g., "http://localhost:3000")
- Ready signal (text that appears when server is ready)
For CLI tools:
- Entry point command (e.g., "node ./cli.js" or "./target/debug/myapp")
- Whether to record with asciinema
For APIs:
- API server command
- Base URL
3. **Authentication & Login** (for web apps and APIs):
Use AskUserQuestion to ask: "Does your app require authentication/login to access the pages or endpoints being verified?"
- **No authentication needed** - App is publicly accessible, no login required
- **Yes, login required** - App requires authentication before verification can proceed
- **Some pages require auth** - Mix of public and authenticated routes
If the user selects login required (or partial), ask follow-up questions:
- **Login method**: How does a user log in?
- Form-based login (username/password on a login page)
- API token/key (passed as header or query param)
- OAuth/SSO (redirect-based flow)
- Other (let user describe)
- **Test credentials**: What credentials should the verifier use?
- Ask for the login URL (e.g., "/login", "http://localhost:3000/auth")
- Ask for test username/email and password, or API key
- Note: Suggest the user use environment variables for secrets (e.g., \`TEST_USER\`, \`TEST_PASSWORD\`) rather than hardcoding
- **Post-login indicator**: How to confirm login succeeded?
- URL redirect (e.g., redirects to "/dashboard")
- Element appears (e.g., "Welcome" text, user avatar)
- Cookie/token is set
## Phase 4: Generate Verifier Skill
**All verifier skills are created in the project root's \`.claude/skills/\` directory.** This ensures they are automatically loaded when Claude runs in the project.
Write the skill file to \`.claude/skills/<verifier-name>/SKILL.md\`.
### Skill Template Structure
\`\`\`markdown
---
name: <verifier-name>
description: <description based on type>
allowed-tools:
# Tools appropriate for the verifier type
---
# <Verifier Title>
You are a verification executor. You receive a verification plan and execute it EXACTLY as written.
## Project Context
<Project-specific details from detection>
## Setup Instructions
<How to start any required services>
## Authentication
<If auth is required, include step-by-step login instructions here>
<Include login URL, credential env vars, and post-login verification>
<If no auth needed, omit this section>
## Reporting
Report PASS or FAIL for each step using the format specified in the verification plan.
## Cleanup
After verification:
1. Stop any dev servers started
2. Close any browser sessions
3. Report final summary
## Self-Update
If verification fails because this skill's instructions are outdated (dev server command/port/ready-signal changed, etc.) — not because the feature under test is broken — or if the user corrects you mid-run, use AskUserQuestion to confirm and then Edit this SKILL.md with a minimal targeted fix.
\`\`\`
### Allowed Tools by Type
**verifier-playwright**:
\`\`\`yaml
allowed-tools:
- Bash(npm:*)
- Bash(yarn:*)
- Bash(pnpm:*)
- Bash(bun:*)
- mcp__playwright__*
- Read
- Glob
- Grep
\`\`\`
**verifier-cli**:
\`\`\`yaml
allowed-tools:
- Tmux
- Bash(asciinema:*)
- Read
- Glob
- Grep
\`\`\`
**verifier-api**:
\`\`\`yaml
allowed-tools:
- Bash(curl:*)
- Bash(http:*)
- Bash(npm:*)
- Bash(yarn:*)
- Read
- Glob
- Grep
\`\`\`
## Phase 5: Confirm Creation
After writing the skill file(s), inform the user:
1. Where each skill was created (always in \`.claude/skills/\`)
2. How the Verify agent will discover them — the folder name must contain "verifier" (case-insensitive) for automatic discovery
3. That they can edit the skills to customize them
4. That they can run /init-verifiers again to add more verifiers for other areas
5. That the verifier will offer to self-update if it detects its own instructions are outdated (wrong dev server command, changed ready signal, etc.)
`,
},
]
},
} satisfies Command
export default command

View File

@@ -0,0 +1,256 @@
import { feature } from 'bun:bundle'
import type { Command } from '../commands.js'
import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'
import { isEnvTruthy } from '../utils/envUtils.js'
const OLD_INIT_PROMPT = `Please analyze this codebase and create a CLAUDE.md file, which will be given to future instances of Claude Code to operate in this repository.
What to add:
1. Commands that will be commonly used, such as how to build, lint, and run tests. Include the necessary commands to develop in this codebase, such as how to run a single test.
2. High-level code architecture and structure so that future instances can be productive more quickly. Focus on the "big picture" architecture that requires reading multiple files to understand.
Usage notes:
- If there's already a CLAUDE.md, suggest improvements to it.
- When you make the initial CLAUDE.md, do not repeat yourself and do not include obvious instructions like "Provide helpful error messages to users", "Write unit tests for all new utilities", "Never include sensitive information (API keys, tokens) in code or commits".
- Avoid listing every component or file structure that can be easily discovered.
- Don't include generic development practices.
- If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include the important parts.
- If there is a README.md, make sure to include the important parts.
- Do not make up information such as "Common Development Tasks", "Tips for Development", "Support and Documentation" unless this is expressly included in other files that you read.
- Be sure to prefix the file with the following text:
\`\`\`
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\``
const NEW_INIT_PROMPT = `Set up a minimal CLAUDE.md (and optionally skills and hooks) for this repo. CLAUDE.md is loaded into every Claude Code session, so it must be concise — only include what Claude would get wrong without it.
## Phase 1: Ask what to set up
Use AskUserQuestion to find out what the user wants:
- "Which CLAUDE.md files should /init set up?"
Options: "Project CLAUDE.md" | "Personal CLAUDE.local.md" | "Both project + personal"
Description for project: "Team-shared instructions checked into source control — architecture, coding standards, common workflows."
Description for personal: "Your private preferences for this project (gitignored, not shared) — your role, sandbox URLs, preferred test data, workflow quirks."
- "Also set up skills and hooks?"
Options: "Skills + hooks" | "Skills only" | "Hooks only" | "Neither, just CLAUDE.md"
Description for skills: "On-demand capabilities you or Claude invoke with \`/skill-name\` — good for repeatable workflows and reference knowledge."
Description for hooks: "Deterministic shell commands that run on tool events (e.g., format after every edit). Claude can't skip them."
## Phase 2: Explore the codebase
Launch a subagent to survey the codebase, and ask it to read key files to understand the project: manifest files (package.json, Cargo.toml, pyproject.toml, go.mod, pom.xml, etc.), README, Makefile/build configs, CI config, existing CLAUDE.md, .claude/rules/, AGENTS.md, .cursor/rules or .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules, .mcp.json.
Detect:
- Build, test, and lint commands (especially non-standard ones)
- Languages, frameworks, and package manager
- Project structure (monorepo with workspaces, multi-module, or single project)
- Code style rules that differ from language defaults
- Non-obvious gotchas, required env vars, or workflow quirks
- Existing .claude/skills/ and .claude/rules/ directories
- Formatter configuration (prettier, biome, ruff, black, gofmt, rustfmt, or a unified format script like \`npm run format\` / \`make fmt\`)
- Git worktree usage: run \`git worktree list\` to check if this repo has multiple worktrees (only relevant if the user wants a personal CLAUDE.local.md)
Note what you could NOT figure out from code alone — these become interview questions.
## Phase 3: Fill in the gaps
Use AskUserQuestion to gather what you still need to write good CLAUDE.md files and skills. Ask only things the code can't answer.
If the user chose project CLAUDE.md or both: ask about codebase practices — non-obvious commands, gotchas, branch/PR conventions, required env setup, testing quirks. Skip things already in README or obvious from manifest files. Do not mark any options as "recommended" — this is about how their team works, not best practices.
If the user chose personal CLAUDE.local.md or both: ask about them, not the codebase. Do not mark any options as "recommended" — this is about their personal preferences, not best practices. Examples of questions:
- What's their role on the team? (e.g., "backend engineer", "data scientist", "new hire onboarding")
- How familiar are they with this codebase and its languages/frameworks? (so Claude can calibrate explanation depth)
- Do they have personal sandbox URLs, test accounts, API key paths, or local setup details Claude should know?
- Only if Phase 2 found multiple git worktrees: ask whether their worktrees are nested inside the main repo (e.g., \`.claude/worktrees/<name>/\`) or siblings/external (e.g., \`../myrepo-feature/\`). If nested, the upward file walk finds the main repo's CLAUDE.local.md automatically — no special handling needed. If sibling/external, the personal content should live in a home-directory file (e.g., \`~/.claude/<project-name>-instructions.md\`) and each worktree gets a one-line CLAUDE.local.md stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. Never put this import in the project CLAUDE.md — that would check a personal reference into the team-shared file.
- Any communication preferences? (e.g., "be terse", "always explain tradeoffs", "don't summarize at the end")
**Synthesize a proposal from Phase 2 findings** — e.g., format-on-edit if a formatter exists, a \`/verify\` skill if tests exist, a CLAUDE.md note for anything from the gap-fill answers that's a guideline rather than a workflow. For each, pick the artifact type that fits, **constrained by the Phase 1 skills+hooks choice**:
- **Hook** (stricter) — deterministic shell command on a tool event; Claude can't skip it. Fits mechanical, fast, per-edit steps: formatting, linting, running a quick test on the changed file.
- **Skill** (on-demand) — you or Claude invoke \`/skill-name\` when you want it. Fits workflows that don't belong on every edit: deep verification, session reports, deploys.
- **CLAUDE.md note** (looser) — influences Claude's behavior but not enforced. Fits communication/thinking preferences: "plan before coding", "be terse", "explain tradeoffs".
**Respect Phase 1's skills+hooks choice as a hard filter**: if the user picked "Skills only", downgrade any hook you'd suggest to a skill or a CLAUDE.md note. If "Hooks only", downgrade skills to hooks (where mechanically possible) or notes. If "Neither", everything becomes a CLAUDE.md note. Never propose an artifact type the user didn't opt into.
**Show the proposal via AskUserQuestion's \`preview\` field, not as a separate text message** — the dialog overlays your output, so preceding text is hidden. The \`preview\` field renders markdown in a side-panel (like plan mode); the \`question\` field is plain-text-only. Structure it as:
- \`question\`: short and plain, e.g. "Does this proposal look right?"
- Each option gets a \`preview\` with the full proposal as markdown. The "Looks good — proceed" option's preview shows everything; per-item-drop options' previews show what remains after that drop.
- **Keep previews compact — the preview box truncates with no scrolling.** One line per item, no blank lines between items, no header. Example preview content:
• **Format-on-edit hook** (automatic) — \`ruff format <file>\` via PostToolUse
• **/verify skill** (on-demand) — \`make lint && make typecheck && make test\`
• **CLAUDE.md note** (guideline) — "run lint/typecheck/test before marking done"
- Option labels stay short ("Looks good", "Drop the hook", "Drop the skill") — the tool auto-adds an "Other" free-text option, so don't add your own catch-all.
**Build the preference queue** from the accepted proposal. Each entry: {type: hook|skill|note, description, target file, any Phase-2-sourced details like the actual test/format command}. Phases 4-7 consume this queue.
## Phase 4: Write CLAUDE.md (if user chose project or both)
Write a minimal CLAUDE.md at the project root. Every line must pass this test: "Would removing this cause Claude to make mistakes?" If no, cut it.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.md** (team-level notes) — add each as a concise line in the most relevant section. These are the behaviors the user wants Claude to follow but didn't need guaranteed (e.g., "propose a plan before implementing", "explain the tradeoffs when refactoring"). Leave personal-targeted notes for Phase 5.
Include:
- Build/test/lint commands Claude can't guess (non-standard scripts, flags, or sequences)
- Code style rules that DIFFER from language defaults (e.g., "prefer type over interface")
- Testing instructions and quirks (e.g., "run single test with: pytest -k 'test_name'")
- Repo etiquette (branch naming, PR conventions, commit style)
- Required env vars or setup steps
- Non-obvious gotchas or architectural decisions
- Important parts from existing AI coding tool configs if they exist (AGENTS.md, .cursor/rules, .cursorrules, .github/copilot-instructions.md, .windsurfrules, .clinerules)
Exclude:
- File-by-file structure or component lists (Claude can discover these by reading the codebase)
- Standard language conventions Claude already knows
- Generic advice ("write clean code", "handle errors")
- Detailed API docs or long references — use \`@path/to/import\` syntax instead (e.g., \`@docs/api-reference.md\`) to inline content on demand without bloating CLAUDE.md
- Information that changes frequently — reference the source with \`@path/to/import\` so Claude always reads the current version
- Long tutorials or walkthroughs (move to a separate file and reference with \`@path/to/import\`, or put in a skill)
- Commands obvious from manifest files (e.g., standard "npm test", "cargo test", "pytest")
Be specific: "Use 2-space indentation in TypeScript" is better than "Format code properly."
Do not repeat yourself and do not make up sections like "Common Development Tasks" or "Tips for Development" — only include information expressly found in files you read.
Prefix the file with:
\`\`\`
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
\`\`\`
If CLAUDE.md already exists: read it, propose specific changes as diffs, and explain why each change improves it. Do not silently overwrite.
For projects with multiple concerns, suggest organizing instructions into \`.claude/rules/\` as separate focused files (e.g., \`code-style.md\`, \`testing.md\`, \`security.md\`). These are loaded automatically alongside CLAUDE.md and can be scoped to specific file paths using \`paths\` frontmatter.
For projects with distinct subdirectories (monorepos, multi-module projects, etc.): mention that subdirectory CLAUDE.md files can be added for module-specific instructions (they're loaded automatically when Claude works in those directories). Offer to create them if the user wants.
## Phase 5: Write CLAUDE.local.md (if user chose personal or both)
Write a minimal CLAUDE.local.md at the project root. This file is automatically loaded alongside CLAUDE.md. After creating it, add \`CLAUDE.local.md\` to the project's .gitignore so it stays private.
**Consume \`note\` entries from the Phase 3 preference queue whose target is CLAUDE.local.md** (personal-level notes) — add each as a concise line. If the user chose personal-only in Phase 1, this is the sole consumer of note entries.
Include:
- The user's role and familiarity with the codebase (so Claude can calibrate explanations)
- Personal sandbox URLs, test accounts, or local setup details
- Personal workflow or communication preferences
Keep it short — only include what would make Claude's responses noticeably better for this user.
If Phase 2 found multiple git worktrees and the user confirmed they use sibling/external worktrees (not nested inside the main repo): the upward file walk won't find a single CLAUDE.local.md from all worktrees. Write the actual personal content to \`~/.claude/<project-name>-instructions.md\` and make CLAUDE.local.md a one-line stub that imports it: \`@~/.claude/<project-name>-instructions.md\`. The user can copy this one-line stub to each sibling worktree. Never put this import in the project CLAUDE.md. If worktrees are nested inside the main repo (e.g., \`.claude/worktrees/\`), no special handling is needed — the main repo's CLAUDE.local.md is found automatically.
If CLAUDE.local.md already exists: read it, propose specific additions, and do not silently overwrite.
## Phase 6: Suggest and create skills (if user chose "Skills + hooks" or "Skills only")
Skills add capabilities Claude can use on demand without bloating every session.
**First, consume \`skill\` entries from the Phase 3 preference queue.** Each queued skill preference becomes a SKILL.md tailored to what the user described. For each:
- Name it from the preference (e.g., "verify-deep", "session-report", "deploy-sandbox")
- Write the body using the user's own words from the interview plus whatever Phase 2 found (test commands, report format, deploy target). If the preference maps to an existing bundled skill (e.g., \`/verify\`), write a project skill that adds the user's specific constraints on top — tell the user the bundled one still exists and theirs is additive.
- Ask a quick follow-up if the preference is underspecified (e.g., "which test command should verify-deep run?")
**Then suggest additional skills** beyond the queue when you find:
- Reference knowledge for specific tasks (conventions, patterns, style guides for a subsystem)
- Repeatable workflows the user would want to trigger directly (deploy, fix an issue, release process, verify changes)
For each suggested skill, provide: name, one-line purpose, and why it fits this repo.
If \`.claude/skills/\` already exists with skills, review them first. Do not overwrite existing skills — only propose new ones that complement what is already there.
Create each skill at \`.claude/skills/<skill-name>/SKILL.md\`:
\`\`\`yaml
---
name: <skill-name>
description: <what the skill does and when to use it>
---
<Instructions for Claude>
\`\`\`
Both the user (\`/<skill-name>\`) and Claude can invoke skills by default. For workflows with side effects (e.g., \`/deploy\`, \`/fix-issue 123\`), add \`disable-model-invocation: true\` so only the user can trigger it, and use \`$ARGUMENTS\` to accept input.
## Phase 7: Suggest additional optimizations
Tell the user you're going to suggest a few additional optimizations now that CLAUDE.md and skills (if chosen) are in place.
Check the environment and ask about each gap you find (use AskUserQuestion):
- **GitHub CLI**: Run \`which gh\` (or \`where gh\` on Windows). If it's missing AND the project uses GitHub (check \`git remote -v\` for github.com), ask the user if they want to install it. Explain that the GitHub CLI lets Claude help with commits, pull requests, issues, and code review directly.
- **Linting**: If Phase 2 found no lint config (no .eslintrc, ruff.toml, .golangci.yml, etc. for the project's language), ask the user if they want Claude to set up linting for this codebase. Explain that linting catches issues early and gives Claude fast feedback on its own edits.
- **Proposal-sourced hooks** (if user chose "Skills + hooks" or "Hooks only"): Consume \`hook\` entries from the Phase 3 preference queue. If Phase 2 found a formatter and the queue has no formatting hook, offer format-on-edit as a fallback. If the user chose "Neither" or "Skills only" in Phase 1, skip this bullet entirely.
For each hook preference (from the queue or the formatter fallback):
1. Target file: default based on the Phase 1 CLAUDE.md choice — project → \`.claude/settings.json\` (team-shared, committed); personal → \`.claude/settings.local.json\`. Only ask if the user chose "both" in Phase 1 or the preference is ambiguous. Ask once for all hooks, not per-hook.
2. Pick the event and matcher from the preference:
- "after every edit" → \`PostToolUse\` with matcher \`Write|Edit\`
- "when Claude finishes" / "before I review" → \`Stop\` event (fires at the end of every turn — including read-only ones)
- "before running bash" → \`PreToolUse\` with matcher \`Bash\`
- "before committing" (literal git-commit gate) → **not a hooks.json hook.** Matchers can't filter Bash by command content, so there's no way to target only \`git commit\`. Route this to a git pre-commit hook (\`.git/hooks/pre-commit\`, husky, pre-commit framework) instead — offer to write one. If the user actually means "before I review and commit Claude's output", that's \`Stop\` — probe to disambiguate.
Probe if the preference is ambiguous.
3. **Load the hook reference** (once per \`/init\` run, before the first hook): invoke the Skill tool with \`skill: 'update-config'\` and args starting with \`[hooks-only]\` followed by a one-line summary of what you're building — e.g., \`[hooks-only] Constructing a PostToolUse/Write|Edit format hook for .claude/settings.json using ruff\`. This loads the hooks schema and verification flow into context. Subsequent hooks reuse it — don't re-invoke.
4. Follow the skill's **"Constructing a Hook"** flow: dedup check → construct for THIS project → pipe-test raw → wrap → write JSON → \`jq -e\` validate → live-proof (for \`Pre|PostToolUse\` on triggerable matchers) → cleanup → handoff. Target file and event/matcher come from steps 12 above.
Act on each "yes" before moving on.
## Phase 8: Summary and next steps
Recap what was set up — which files were written and the key points included in each. Remind the user these files are a starting point: they should review and tweak them, and can run \`/init\` again anytime to re-scan.
Then tell the user that you'll be introducing a few more suggestions for optimizing their codebase and Claude Code setup based on what you found. Present these as a single, well-formatted to-do list where every item is relevant to this repo. Put the most impactful items first.
When building the list, work through these checks and include only what applies:
- If frontend code was detected (React, Vue, Svelte, etc.): \`/plugin install frontend-design@claude-plugins-official\` gives Claude design principles and component patterns so it produces polished UI; \`/plugin install playwright@claude-plugins-official\` lets Claude launch a real browser, screenshot what it built, and fix visual bugs itself.
- If you found gaps in Phase 7 (missing GitHub CLI, missing linting) and the user said no: list them here with a one-line reason why each helps.
- If tests are missing or sparse: suggest setting up a test framework so Claude can verify its own changes.
- To help you create skills and optimize existing skills using evals, Claude Code has an official skill-creator plugin you can install. Install it with \`/plugin install skill-creator@claude-plugins-official\`, then run \`/skill-creator <skill-name>\` to create new skills or refine any existing skill. (Always include this one.)
- Browse official plugins with \`/plugin\` — these bundle skills, agents, hooks, and MCP servers that you may find helpful. You can also create your own custom plugins to share them with others. (Always include this one.)`
const command = {
type: 'prompt',
name: 'init',
get description() {
return feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? 'Initialize new CLAUDE.md file(s) and optional skills/hooks with codebase documentation'
: 'Initialize a new CLAUDE.md file with codebase documentation'
},
contentLength: 0, // Dynamic content
progressMessage: 'analyzing your codebase',
source: 'builtin',
async getPromptForCommand() {
maybeMarkProjectOnboardingComplete()
return [
{
type: 'text',
text:
feature('NEW_INIT') &&
(process.env.USER_TYPE === 'ant' ||
isEnvTruthy(process.env.CLAUDE_CODE_NEW_INIT))
? NEW_INIT_PROMPT
: OLD_INIT_PROMPT,
},
]
},
} satisfies Command
export default command

View File

@@ -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

View File

@@ -0,0 +1,325 @@
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from 'src/services/analytics/index.js'
import { saveGlobalConfig } from 'src/utils/config.js'
import {
CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT,
PR_BODY,
PR_TITLE,
WORKFLOW_CONTENT,
} from '../../constants/github-app.js'
import { openBrowser } from '../../utils/browser.js'
import { execFileNoThrow } from '../../utils/execFileNoThrow.js'
import { logError } from '../../utils/log.js'
import type { Workflow } from './types.js'
async function createWorkflowFile(
repoName: string,
branchName: string,
workflowPath: string,
workflowContent: string,
secretName: string,
message: string,
context?: {
useCurrentRepo?: boolean
workflowExists?: boolean
secretExists?: boolean
},
): Promise<void> {
// 检查工作流文件是否已存在
const checkFileResult = await execFileNoThrow('gh', [
'api',
`repos/${repoName}/contents/${workflowPath}`,
'--jq',
'.sha',
])
let fileSha: string | null = null
if (checkFileResult.code === 0) {
fileSha = checkFileResult.stdout.trim()
}
let content = workflowContent
if (secretName === 'CLAUDE_CODE_OAUTH_TOKEN') {
// 对于 OAuth 令牌,使用 claude_code_oauth_token 参数
content = workflowContent.replace(
/anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g,
`claude_code_oauth_token: \${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}`,
)
} else if (secretName !== 'ANTHROPIC_API_KEY') {
// 对于其他自定义密钥名称,继续使用 anthropic_api_key 参数
content = workflowContent.replace(
/anthropic_api_key: \$\{\{ secrets\.ANTHROPIC_API_KEY \}\}/g,
`anthropic_api_key: \${{ secrets.${secretName} }}`,
)
}
const base64Content = Buffer.from(content).toString('base64')
const apiParams = [
'api',
'--method',
'PUT',
`repos/${repoName}/contents/${workflowPath}`,
'-f',
`message=${fileSha ? `"Update ${message}"` : `"${message}"`}`,
'-f',
`content=${base64Content}`,
'-f',
`branch=${branchName}`,
]
if (fileSha) {
apiParams.push('-f', `sha=${fileSha}`)
}
const createFileResult = await execFileNoThrow('gh', apiParams)
if (createFileResult.code !== 0) {
if (
createFileResult.stderr.includes('422') &&
createFileResult.stderr.includes('sha')
) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: createFileResult.code,
...context,
})
throw new Error(
`Failed to create workflow file ${workflowPath}: A Claude workflow file already exists in this repository. Please remove it first or update it manually.`,
)
}
logEvent('tengu_setup_github_actions_failed', {
reason:
'failed_to_create_workflow_file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: createFileResult.code,
...context,
})
const helpText =
'\n\nNeed help? Common issues:\n' +
'· Permission denied → Run: gh auth refresh -h github.com -s repo,workflow\n' +
'· Not authorized → Ensure you have admin access to the repository\n' +
'· For manual setup → Visit: https://github.com/anthropics/claude-code-action'
throw new Error(
`Failed to create workflow file ${workflowPath}: ${createFileResult.stderr}${helpText}`,
)
}
}
export async function setupGitHubActions(
repoName: string,
apiKeyOrOAuthToken: string | null,
secretName: string,
updateProgress: () => void,
skipWorkflow = false,
selectedWorkflows: Workflow[],
authType: 'api_key' | 'oauth_token',
context?: {
useCurrentRepo?: boolean
workflowExists?: boolean
secretExists?: boolean
},
) {
try {
logEvent('tengu_setup_github_actions_started', {
skip_workflow: skipWorkflow,
has_api_key: !!apiKeyOrOAuthToken,
using_default_secret_name: secretName === 'ANTHROPIC_API_KEY',
selected_claude_workflow: selectedWorkflows.includes('claude'),
selected_claude_review_workflow:
selectedWorkflows.includes('claude-review'),
...context,
})
// 检查仓库是否存在
const repoCheckResult = await execFileNoThrow('gh', [
'api',
`repos/${repoName}`,
'--jq',
'.id',
])
if (repoCheckResult.code !== 0) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'repo_not_found' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: repoCheckResult.code,
...context,
})
throw new Error(
`Failed to access repository ${repoName}: ${repoCheckResult.stderr}`,
)
}
// 获取默认分支
const defaultBranchResult = await execFileNoThrow('gh', [
'api',
`repos/${repoName}`,
'--jq',
'.default_branch',
])
if (defaultBranchResult.code !== 0) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'failed_to_get_default_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: defaultBranchResult.code,
...context,
})
throw new Error(
`Failed to get default branch: ${defaultBranchResult.stderr}`,
)
}
const defaultBranch = defaultBranchResult.stdout.trim()
// 获取默认分支的 SHA
const shaResult = await execFileNoThrow('gh', [
'api',
`repos/${repoName}/git/ref/heads/${defaultBranch}`,
'--jq',
'.object.sha',
])
if (shaResult.code !== 0) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'failed_to_get_branch_sha' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: shaResult.code,
...context,
})
throw new Error(`Failed to get branch SHA: ${shaResult.stderr}`)
}
const sha = shaResult.stdout.trim()
let branchName: string | null = null
if (!skipWorkflow) {
updateProgress()
// 创建新分支
branchName = `add-claude-github-actions-${Date.now()}`
const createBranchResult = await execFileNoThrow('gh', [
'api',
'--method',
'POST',
`repos/${repoName}/git/refs`,
'-f',
`ref=refs/heads/${branchName}`,
'-f',
`sha=${sha}`,
])
if (createBranchResult.code !== 0) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'failed_to_create_branch' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: createBranchResult.code,
...context,
})
throw new Error(`Failed to create branch: ${createBranchResult.stderr}`)
}
updateProgress()
// 创建所选的工作流文件
const workflows = []
if (selectedWorkflows.includes('claude')) {
workflows.push({
path: '.github/workflows/claude.yml',
content: WORKFLOW_CONTENT,
message: 'Claude PR Assistant workflow',
})
}
if (selectedWorkflows.includes('claude-review')) {
workflows.push({
path: '.github/workflows/claude-code-review.yml',
content: CODE_REVIEW_PLUGIN_WORKFLOW_CONTENT,
message: 'Claude Code Review workflow',
})
}
for (const workflow of workflows) {
await createWorkflowFile(
repoName,
branchName,
workflow.path,
workflow.content,
secretName,
workflow.message,
context,
)
}
}
updateProgress()
// 如果提供了 API 密钥,则将其设置为密钥
if (apiKeyOrOAuthToken) {
const setSecretResult = await execFileNoThrow('gh', [
'secret',
'set',
secretName,
'--body',
apiKeyOrOAuthToken,
'--repo',
repoName,
])
if (setSecretResult.code !== 0) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'failed_to_set_api_key_secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
exit_code: setSecretResult.code,
...context,
})
const helpText =
'\n\nNeed help? Common issues:\n' +
'· Permission denied → Run: gh auth refresh -h github.com -s repo\n' +
'· Not authorized → Ensure you have admin access to the repository\n' +
'· For manual setup → Visit: https://github.com/anthropics/claude-code-action'
throw new Error(
`Failed to set API key secret: ${setSecretResult.stderr || 'Unknown error'}${helpText}`,
)
}
}
if (!skipWorkflow && branchName) {
updateProgress()
// 创建 PR 模板 URL 而不是直接创建 PR
const compareUrl = `https://github.com/${repoName}/compare/${defaultBranch}...${branchName}?quick_pull=1&title=${encodeURIComponent(PR_TITLE)}&body=${encodeURIComponent(PR_BODY)}`
await openBrowser(compareUrl)
}
logEvent('tengu_setup_github_actions_completed', {
skip_workflow: skipWorkflow,
has_api_key: !!apiKeyOrOAuthToken,
auth_type:
authType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
using_default_secret_name: secretName === 'ANTHROPIC_API_KEY',
selected_claude_workflow: selectedWorkflows.includes('claude'),
selected_claude_review_workflow:
selectedWorkflows.includes('claude-review'),
...context,
})
saveGlobalConfig(current => ({
...current,
githubActionSetupCount: (current.githubActionSetupCount ?? 0) + 1,
}))
} catch (error) {
if (
!error ||
!(error instanceof Error) ||
!error.message.includes('Failed to')
) {
logEvent('tengu_setup_github_actions_failed', {
reason:
'unexpected_error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
...context,
})
}
if (error instanceof Error) {
logError(error)
}
throw error
}
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const installSlackApp = {
type: 'local',
name: 'install-slack-app',
description: 'Install the Claude Slack app',
availability: ['claude-ai'],
supportsNonInteractive: false,
load: () => import('./install-slack-app.js'),
} satisfies Command
export default installSlackApp

View File

@@ -0,0 +1,30 @@
import type { LocalCommandResult } from '../../commands.js'
import { logEvent } from '../../services/analytics/index.js'
import { openBrowser } from '../../utils/browser.js'
import { saveGlobalConfig } from '../../utils/config.js'
const SLACK_APP_URL = 'https://slack.com/marketplace/A08SF47R6P4-claude'
export async function call(): Promise<LocalCommandResult> {
logEvent('tengu_install_slack_app_clicked', {})
// Track that user has clicked to install
saveGlobalConfig(current => ({
...current,
slackAppInstallCount: (current.slackAppInstallCount ?? 0) + 1,
}))
const success = await openBrowser(SLACK_APP_URL)
if (success) {
return {
type: 'text',
value: 'Opening Slack app installation page in browser…',
}
} else {
return {
type: 'text',
value: `Couldn't open browser. Visit: ${SLACK_APP_URL}`,
}
}
}

View File

@@ -0,0 +1,13 @@
import type { Command } from '../../commands.js'
import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'
const keybindings = {
name: 'keybindings',
description: 'Open or create your keybindings configuration file',
isEnabled: () => isKeybindingCustomizationEnabled(),
supportsNonInteractive: false,
type: 'local',
load: () => import('./keybindings.js'),
} satisfies Command
export default keybindings

View File

@@ -0,0 +1,53 @@
import { mkdir, writeFile } from 'fs/promises'
import { dirname } from 'path'
import {
getKeybindingsPath,
isKeybindingCustomizationEnabled,
} from '../../keybindings/loadUserBindings.js'
import { generateKeybindingsTemplate } from '../../keybindings/template.js'
import { getErrnoCode } from '../../utils/errors.js'
import { editFileInEditor } from '../../utils/promptEditor.js'
export async function call(): Promise<{ type: 'text'; value: string }> {
if (!isKeybindingCustomizationEnabled()) {
return {
type: 'text',
value:
'Keybinding customization is not enabled. This feature is currently in preview.',
}
}
const keybindingsPath = getKeybindingsPath()
// 使用 'wx' 标志(独占创建)写入模板 — 如果
// 文件已存在则失败并显示 EEXIST。避免预检查 statTOCTOU 竞争 + 额外 syscall
let fileExists = false
await mkdir(dirname(keybindingsPath), { recursive: true })
try {
await writeFile(keybindingsPath, generateKeybindingsTemplate(), {
encoding: 'utf-8',
flag: 'wx',
})
} catch (e: unknown) {
if (getErrnoCode(e) === 'EEXIST') {
fileExists = true
} else {
throw e
}
}
// 在编辑器中打开
const result = await editFileInEditor(keybindingsPath)
if (result.error) {
return {
type: 'text',
value: `${fileExists ? 'Opened' : 'Created'} ${keybindingsPath}. Could not open in editor: ${result.error}`,
}
}
return {
type: 'text',
value: fileExists
? `Opened ${keybindingsPath} in your editor.`
: `Created ${keybindingsPath} with template. Opened in your editor.`,
}
}

View File

@@ -0,0 +1,10 @@
import type { Command } from '../../commands.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
export default {
type: 'local-jsx',
name: 'logout',
description: 'Sign out from your Anthropic account',
isEnabled: () => !isEnvTruthy(process.env.DISABLE_LOGOUT_COMMAND),
load: () => import('./logout.js'),
} satisfies Command

View File

@@ -0,0 +1,280 @@
/**
* MCP add CLI subcommand
*
* Extracted from main.tsx to enable direct testing.
*/
import { type Command, Option } from '@commander-js/extra-typings'
import { cliError, cliOk } from '../../cli/exit.js'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import {
readClientSecret,
saveMcpClientSecret,
} from '../../services/mcp/auth.js'
import { addMcpConfig } from '../../services/mcp/config.js'
import {
describeMcpConfigFilePath,
ensureConfigScope,
ensureTransport,
parseHeaders,
} from '../../services/mcp/utils.js'
import {
getXaaIdpSettings,
isXaaEnabled,
} from '../../services/mcp/xaaIdpLogin.js'
import { parseEnvVars } from '../../utils/envUtils.js'
import { jsonStringify } from '../../utils/slowOperations.js'
/**
* Registers the `mcp add` subcommand on the given Commander command.
*/
export function registerMcpAddCommand(mcp: Command): void {
mcp
.command('add <name> <commandOrUrl> [args...]')
.description(
'Add an MCP server to Claude Code.\n\n' +
'Examples:\n' +
' # Add HTTP server:\n' +
' claude mcp add --transport http sentry https://mcp.sentry.dev/mcp\n\n' +
' # Add HTTP server with headers:\n' +
' claude mcp add --transport http corridor https://app.corridor.dev/api/mcp --header "Authorization: Bearer ..."\n\n' +
' # Add stdio server with environment variables:\n' +
' claude mcp add -e API_KEY=xxx my-server -- npx my-mcp-server\n\n' +
' # Add stdio server with subprocess flags:\n' +
' claude mcp add my-server -- my-command --some-flag arg1',
)
.option(
'-s, --scope <scope>',
'Configuration scope (local, user, or project)',
'local',
)
.option(
'-t, --transport <transport>',
'Transport type (stdio, sse, http). Defaults to stdio if not specified.',
)
.option(
'-e, --env <env...>',
'Set environment variables (e.g. -e KEY=value)',
)
.option(
'-H, --header <header...>',
'Set WebSocket headers (e.g. -H "X-Api-Key: abc123" -H "X-Custom: value")',
)
.option('--client-id <clientId>', 'OAuth client ID for HTTP/SSE servers')
.option(
'--client-secret',
'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)',
)
.option(
'--callback-port <port>',
'Fixed port for OAuth callback (for servers requiring pre-registered redirect URIs)',
)
.helpOption('-h, --help', 'Display help for command')
.addOption(
new Option(
'--xaa',
"Enable XAA (SEP-990) for this server. Requires 'claude mcp xaa setup' first. Also requires --client-id and --client-secret (for the MCP server's AS).",
).hideHelp(!isXaaEnabled()),
)
.action(async (name, commandOrUrl, args, options) => {
// Commander.js handles -- natively: it consumes -- and everything after becomes args
const actualCommand = commandOrUrl
const actualArgs = args
// If no name is provided, error
if (!name) {
cliError(
'Error: Server name is required.\n' +
'Usage: claude mcp add <name> <command> [args...]',
)
} else if (!actualCommand) {
cliError(
'Error: Command is required when server name is provided.\n' +
'Usage: claude mcp add <name> <command> [args...]',
)
}
try {
const scope = ensureConfigScope(options.scope)
const transport = ensureTransport(options.transport)
// XAA fail-fast: validate at add-time, not auth-time.
if (options.xaa && !isXaaEnabled()) {
cliError(
'Error: --xaa requires CLAUDE_CODE_ENABLE_XAA=1 in your environment',
)
}
const xaa = Boolean(options.xaa)
if (xaa) {
const missing: string[] = []
if (!options.clientId) missing.push('--client-id')
if (!options.clientSecret) missing.push('--client-secret')
if (!getXaaIdpSettings()) {
missing.push(
"'claude mcp xaa setup' (settings.xaaIdp not configured)",
)
}
if (missing.length) {
cliError(`Error: --xaa requires: ${missing.join(', ')}`)
}
}
// Check if transport was explicitly provided
const transportExplicit = options.transport !== undefined
// Check if the command looks like a URL (likely incorrect usage)
const looksLikeUrl =
actualCommand.startsWith('http://') ||
actualCommand.startsWith('https://') ||
actualCommand.startsWith('localhost') ||
actualCommand.endsWith('/sse') ||
actualCommand.endsWith('/mcp')
logEvent('tengu_mcp_add', {
type: transport as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
scope:
scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
source:
'command' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
transport:
transport as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
transportExplicit: transportExplicit,
looksLikeUrl: looksLikeUrl,
})
if (transport === 'sse') {
if (!actualCommand) {
cliError('Error: URL is required for SSE transport.')
}
const headers = options.header
? parseHeaders(options.header)
: undefined
const callbackPort = options.callbackPort
? parseInt(options.callbackPort, 10)
: undefined
const oauth =
options.clientId || callbackPort || xaa
? {
...(options.clientId ? { clientId: options.clientId } : {}),
...(callbackPort ? { callbackPort } : {}),
...(xaa ? { xaa: true } : {}),
}
: undefined
const clientSecret =
options.clientSecret && options.clientId
? await readClientSecret()
: undefined
const serverConfig = {
type: 'sse' as const,
url: actualCommand,
headers,
oauth,
}
await addMcpConfig(name, serverConfig, scope)
if (clientSecret) {
saveMcpClientSecret(name, serverConfig, clientSecret)
}
process.stdout.write(
`Added SSE MCP server ${name} with URL: ${actualCommand} to ${scope} config\n`,
)
if (headers) {
process.stdout.write(
`Headers: ${jsonStringify(headers, null, 2)}\n`,
)
}
} else if (transport === 'http') {
if (!actualCommand) {
cliError('Error: URL is required for HTTP transport.')
}
const headers = options.header
? parseHeaders(options.header)
: undefined
const callbackPort = options.callbackPort
? parseInt(options.callbackPort, 10)
: undefined
const oauth =
options.clientId || callbackPort || xaa
? {
...(options.clientId ? { clientId: options.clientId } : {}),
...(callbackPort ? { callbackPort } : {}),
...(xaa ? { xaa: true } : {}),
}
: undefined
const clientSecret =
options.clientSecret && options.clientId
? await readClientSecret()
: undefined
const serverConfig = {
type: 'http' as const,
url: actualCommand,
headers,
oauth,
}
await addMcpConfig(name, serverConfig, scope)
if (clientSecret) {
saveMcpClientSecret(name, serverConfig, clientSecret)
}
process.stdout.write(
`Added HTTP MCP server ${name} with URL: ${actualCommand} to ${scope} config\n`,
)
if (headers) {
process.stdout.write(
`Headers: ${jsonStringify(headers, null, 2)}\n`,
)
}
} else {
if (
options.clientId ||
options.clientSecret ||
options.callbackPort ||
options.xaa
) {
process.stderr.write(
`Warning: --client-id, --client-secret, --callback-port, and --xaa are only supported for HTTP/SSE transports and will be ignored for stdio.\n`,
)
}
// Warn if this looks like a URL but transport wasn't explicitly specified
if (!transportExplicit && looksLikeUrl) {
process.stderr.write(
`\nWarning: The command "${actualCommand}" looks like a URL, but is being interpreted as a stdio server as --transport was not specified.\n`,
)
process.stderr.write(
`If this is an HTTP server, use: claude mcp add --transport http ${name} ${actualCommand}\n`,
)
process.stderr.write(
`If this is an SSE server, use: claude mcp add --transport sse ${name} ${actualCommand}\n`,
)
}
const env = parseEnvVars(options.env)
await addMcpConfig(
name,
{ type: 'stdio', command: actualCommand, args: actualArgs, env },
scope,
)
process.stdout.write(
`Added stdio MCP server ${name} with command: ${actualCommand} ${actualArgs.join(' ')} to ${scope} config\n`,
)
}
cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`)
} catch (error) {
cliError((error as Error).message)
}
})
}

View File

@@ -0,0 +1,12 @@
import type { Command } from '../../commands.js'
const mcp = {
type: 'local-jsx',
name: 'mcp',
description: 'Manage MCP servers',
immediate: true,
argumentHint: '[enable|disable [server-name]]',
load: () => import('./mcp.js'),
} satisfies Command
export default mcp

View File

@@ -0,0 +1,266 @@
/**
* `claude mcp xaa` — manage the XAA (SEP-990) IdP connection.
*
* The IdP connection is user-level: configure once, all XAA-enabled MCP
* servers reuse it. Lives in settings.xaaIdp (non-secret) + a keychain slot
* keyed by issuer (secret). Separate trust domain from per-server AS secrets.
*/
import type { Command } from '@commander-js/extra-typings'
import { cliError, cliOk } from '../../cli/exit.js'
import {
acquireIdpIdToken,
clearIdpClientSecret,
clearIdpIdToken,
getCachedIdpIdToken,
getIdpClientSecret,
getXaaIdpSettings,
issuerKey,
saveIdpClientSecret,
saveIdpIdTokenFromJwt,
} from '../../services/mcp/xaaIdpLogin.js'
import { errorMessage } from '../../utils/errors.js'
import { updateSettingsForSource } from '../../utils/settings/settings.js'
export function registerMcpXaaIdpCommand(mcp: Command): void {
const xaaIdp = mcp
.command('xaa')
.description('Manage the XAA (SEP-990) IdP connection')
xaaIdp
.command('setup')
.description(
'Configure the IdP connection (one-time setup for all XAA-enabled servers)',
)
.requiredOption('--issuer <url>', 'IdP issuer URL (OIDC discovery)')
.requiredOption('--client-id <id>', "Claude Code's client_id at the IdP")
.option(
'--client-secret',
'Read IdP client secret from MCP_XAA_IDP_CLIENT_SECRET env var',
)
.option(
'--callback-port <port>',
'Fixed loopback callback port (only if IdP does not honor RFC 8252 port-any matching)',
)
.action(options => {
// Validate everything BEFORE any writes. An exit(1) mid-write leaves
// settings configured but keychain missing — confusing state.
// updateSettingsForSource doesn't schema-check on write; a non-URL
// issuer lands on disk and then poisons the whole userSettings source
// on next launch (SettingsSchema .url() fails → parseSettingsFile
// returns { settings: null }, dropping everything, not just xaaIdp).
let issuerUrl: URL
try {
issuerUrl = new URL(options.issuer)
} catch {
return cliError(
`Error: --issuer must be a valid URL (got "${options.issuer}")`,
)
}
// OIDC discovery + token exchange run against this host. Allow http://
// only for loopback (conformance harness mock IdP); anything else leaks
// the client secret and authorization code over plaintext.
if (
issuerUrl.protocol !== 'https:' &&
!(
issuerUrl.protocol === 'http:' &&
(issuerUrl.hostname === 'localhost' ||
issuerUrl.hostname === '127.0.0.1' ||
issuerUrl.hostname === '[::1]')
)
) {
return cliError(
`Error: --issuer must use https:// (got "${issuerUrl.protocol}//${issuerUrl.host}")`,
)
}
const callbackPort = options.callbackPort
? parseInt(options.callbackPort, 10)
: undefined
// callbackPort <= 0 fails Zod's .positive() on next launch — same
// settings-poisoning failure mode as the issuer check above.
if (
callbackPort !== undefined &&
(!Number.isInteger(callbackPort) || callbackPort <= 0)
) {
return cliError('Error: --callback-port must be a positive integer')
}
const secret = options.clientSecret
? process.env.MCP_XAA_IDP_CLIENT_SECRET
: undefined
if (options.clientSecret && !secret) {
return cliError(
'Error: --client-secret requires MCP_XAA_IDP_CLIENT_SECRET env var',
)
}
// Read old config now (before settings overwrite) so we can clear stale
// keychain slots after a successful write. `clear` can't do this after
// the fact — it reads the *current* settings.xaaIdp, which by then is
// the new one.
const old = getXaaIdpSettings()
const oldIssuer = old?.issuer
const oldClientId = old?.clientId
// callbackPort MUST be present (even as undefined) — mergeWith deep-merges
// and only deletes on explicit `undefined`, not on absent key. A conditional
// spread would leak a prior fixed port into a new IdP's config.
const { error } = updateSettingsForSource('userSettings', {
xaaIdp: {
issuer: options.issuer,
clientId: options.clientId,
callbackPort,
},
})
if (error) {
return cliError(`Error writing settings: ${error.message}`)
}
// Clear stale keychain slots only after settings write succeeded —
// otherwise a write failure leaves settings pointing at oldIssuer with
// its secret already gone. Compare via issuerKey(): trailing-slash or
// host-case differences normalize to the same keychain slot.
if (oldIssuer) {
if (issuerKey(oldIssuer) !== issuerKey(options.issuer)) {
clearIdpIdToken(oldIssuer)
clearIdpClientSecret(oldIssuer)
} else if (oldClientId !== options.clientId) {
// Same issuer slot but different OAuth client registration — the
// cached id_token's aud claim and the stored secret are both for the
// old client. `xaa login` would send {new clientId, old secret} and
// fail with opaque `invalid_client`; downstream SEP-990 exchange
// would fail aud validation. Keep both when clientId is unchanged:
// re-setup without --client-secret means "tweak port, keep secret".
clearIdpIdToken(oldIssuer)
clearIdpClientSecret(oldIssuer)
}
}
if (secret) {
const { success, warning } = saveIdpClientSecret(options.issuer, secret)
if (!success) {
return cliError(
`Error: settings written but keychain save failed${warning ? `${warning}` : ''}. ` +
`Re-run with --client-secret once keychain is available.`,
)
}
}
cliOk(`XAA IdP connection configured for ${options.issuer}`)
})
xaaIdp
.command('login')
.description(
'Cache an IdP id_token so XAA-enabled MCP servers authenticate ' +
'silently. Default: run the OIDC browser login. With --id-token: ' +
'write a pre-obtained JWT directly (used by conformance/e2e tests ' +
'where the mock IdP does not serve /authorize).',
)
.option(
'--force',
'Ignore any cached id_token and re-login (useful after IdP-side revocation)',
)
// TODO(paulc): read the JWT from stdin instead of argv to keep it out of
// shell history. Fine for conformance (docker exec uses argv directly,
// no shell parser), but a real user would want `echo $TOKEN | ... --stdin`.
.option(
'--id-token <jwt>',
'Write this pre-obtained id_token directly to cache, skipping the OIDC browser login',
)
.action(async options => {
const idp = getXaaIdpSettings()
if (!idp) {
return cliError(
"Error: no XAA IdP connection. Run 'claude mcp xaa setup' first.",
)
}
// Direct-inject path: skip cache check, skip OIDC. Writing IS the
// operation. Issuer comes from settings (single source of truth), not
// a separate flag — one less thing to desync.
if (options.idToken) {
const expiresAt = saveIdpIdTokenFromJwt(idp.issuer, options.idToken)
return cliOk(
`id_token cached for ${idp.issuer} (expires ${new Date(expiresAt).toISOString()})`,
)
}
if (options.force) {
clearIdpIdToken(idp.issuer)
}
const wasCached = getCachedIdpIdToken(idp.issuer) !== undefined
if (wasCached) {
return cliOk(
`Already logged in to ${idp.issuer} (cached id_token still valid). Use --force to re-login.`,
)
}
process.stdout.write(`Opening browser for IdP login at ${idp.issuer}\n`)
try {
await acquireIdpIdToken({
idpIssuer: idp.issuer,
idpClientId: idp.clientId,
idpClientSecret: getIdpClientSecret(idp.issuer),
callbackPort: idp.callbackPort,
onAuthorizationUrl: url => {
process.stdout.write(
`If the browser did not open, visit:\n ${url}\n`,
)
},
})
cliOk(
`Logged in. MCP servers with --xaa will now authenticate silently.`,
)
} catch (e) {
cliError(`IdP login failed: ${errorMessage(e)}`)
}
})
xaaIdp
.command('show')
.description('Show the current IdP connection config')
.action(() => {
const idp = getXaaIdpSettings()
if (!idp) {
return cliOk('No XAA IdP connection configured.')
}
const hasSecret = getIdpClientSecret(idp.issuer) !== undefined
const hasIdToken = getCachedIdpIdToken(idp.issuer) !== undefined
process.stdout.write(`Issuer: ${idp.issuer}\n`)
process.stdout.write(`Client ID: ${idp.clientId}\n`)
if (idp.callbackPort !== undefined) {
process.stdout.write(`Callback port: ${idp.callbackPort}\n`)
}
process.stdout.write(
`Client secret: ${hasSecret ? '(stored in keychain)' : '(not set — PKCE-only)'}\n`,
)
process.stdout.write(
`Logged in: ${hasIdToken ? 'yes (id_token cached)' : "no — run 'claude mcp xaa login'"}\n`,
)
cliOk()
})
xaaIdp
.command('clear')
.description('Clear the IdP connection config and cached id_token')
.action(() => {
// Read issuer first so we can clear the right keychain slots.
const idp = getXaaIdpSettings()
// updateSettingsForSource uses mergeWith: set to undefined (not delete)
// to signal key removal.
const { error } = updateSettingsForSource('userSettings', {
xaaIdp: undefined,
})
if (error) {
return cliError(`Error writing settings: ${error.message}`)
}
// Clear keychain only after settings write succeeded — otherwise a
// write failure leaves settings pointing at the IdP with its secrets
// already gone (same pattern as `setup`'s old-issuer cleanup).
if (idp) {
clearIdpIdToken(idp.issuer)
clearIdpClientSecret(idp.issuer)
}
cliOk('XAA IdP connection cleared')
})
}

View File

@@ -0,0 +1,10 @@
import type { Command } from '../../commands.js'
const memory: Command = {
type: 'local-jsx',
name: 'memory',
description: 'Edit Claude memory files',
load: () => import('./memory.js'),
}
export default memory

View File

@@ -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

View File

@@ -0,0 +1,16 @@
import type { Command } from '../../commands.js'
import { shouldInferenceConfigCommandBeImmediate } from '../../utils/immediateCommand.js'
import { getMainLoopModel, renderModelName } from '../../utils/model/model.js'
export default {
type: 'local-jsx',
name: 'model',
get description() {
return `Set the AI model for Claude Code (currently ${renderModelName(getMainLoopModel())})`
},
argumentHint: '[model]',
get immediate() {
return shouldInferenceConfigCommandBeImmediate()
},
load: () => import('./model.js'),
} satisfies Command

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const outputStyle = {
type: 'local-jsx',
name: 'output-style',
description: 'Deprecated: use /config to change output style',
isHidden: true,
load: () => import('./output-style.js'),
} satisfies Command
export default outputStyle

View File

@@ -0,0 +1,22 @@
import type { Command } from '../../commands.js'
import {
checkCachedPassesEligibility,
getCachedReferrerReward,
} from '../../services/api/referral.js'
export default {
type: 'local-jsx',
name: 'passes',
get description() {
const reward = getCachedReferrerReward()
if (reward) {
return 'Share a free week of Claude Code with friends and earn extra usage'
}
return 'Share a free week of Claude Code with friends'
},
get isHidden() {
const { eligible, hasCache } = checkCachedPassesEligibility()
return !eligible || !hasCache
},
load: () => import('./passes.js'),
} satisfies Command

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const permissions = {
type: 'local-jsx',
name: 'permissions',
aliases: ['allowed-tools'],
description: 'Manage allow & deny tool permission rules',
load: () => import('./permissions.js'),
} satisfies Command
export default permissions

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const plan = {
type: 'local-jsx',
name: 'plan',
description: 'Enable plan mode or view the current session plan',
argumentHint: '[open|<description>]',
load: () => import('./plan.js'),
} satisfies Command
export default plan

View File

@@ -0,0 +1,103 @@
// Parse plugin subcommand arguments into structured commands
export type ParsedCommand =
| { type: 'menu' }
| { type: 'help' }
| { type: 'install'; marketplace?: string; plugin?: string }
| { type: 'manage' }
| { type: 'uninstall'; plugin?: string }
| { type: 'enable'; plugin?: string }
| { type: 'disable'; plugin?: string }
| { type: 'validate'; path?: string }
| {
type: 'marketplace'
action?: 'add' | 'remove' | 'update' | 'list'
target?: string
}
export function parsePluginArgs(args?: string): ParsedCommand {
if (!args) {
return { type: 'menu' }
}
const parts = args.trim().split(/\s+/)
const command = parts[0]?.toLowerCase()
switch (command) {
case 'help':
case '--help':
case '-h':
return { type: 'help' }
case 'install':
case 'i': {
const target = parts[1]
if (!target) {
return { type: 'install' }
}
// Check if it's in format plugin@marketplace
if (target.includes('@')) {
const [plugin, marketplace] = target.split('@')
return { type: 'install', plugin, marketplace }
}
// Check if the target looks like a marketplace (URL or path)
const isMarketplace =
target.startsWith('http://') ||
target.startsWith('https://') ||
target.startsWith('file://') ||
target.includes('/') ||
target.includes('\\')
if (isMarketplace) {
// This is a marketplace URL/path, no plugin specified
return { type: 'install', marketplace: target }
}
// Otherwise treat it as a plugin name
return { type: 'install', plugin: target }
}
case 'manage':
return { type: 'manage' }
case 'uninstall':
return { type: 'uninstall', plugin: parts[1] }
case 'enable':
return { type: 'enable', plugin: parts[1] }
case 'disable':
return { type: 'disable', plugin: parts[1] }
case 'validate': {
const target = parts.slice(1).join(' ').trim()
return { type: 'validate', path: target || undefined }
}
case 'marketplace':
case 'market': {
const action = parts[1]?.toLowerCase()
const target = parts.slice(2).join(' ')
switch (action) {
case 'add':
return { type: 'marketplace', action: 'add', target }
case 'remove':
case 'rm':
return { type: 'marketplace', action: 'remove', target }
case 'update':
return { type: 'marketplace', action: 'update', target }
case 'list':
return { type: 'marketplace', action: 'list' }
default:
// No action specified, show marketplace menu
return { type: 'marketplace' }
}
}
default:
// Unknown command, show menu
return { type: 'menu' }
}
}

View File

@@ -0,0 +1,171 @@
import { useCallback, useMemo, useRef } from 'react'
const DEFAULT_MAX_VISIBLE = 5
type UsePaginationOptions = {
totalItems: number
maxVisible?: number
selectedIndex?: number
}
type UsePaginationResult<T> = {
// For backwards compatibility with page-based terminology
currentPage: number
totalPages: number
startIndex: number
endIndex: number
needsPagination: boolean
pageSize: number
// Get visible slice of items
getVisibleItems: (items: T[]) => T[]
// Convert visible index to actual index
toActualIndex: (visibleIndex: number) => number
// Check if actual index is visible
isOnCurrentPage: (actualIndex: number) => boolean
// Navigation (kept for API compatibility)
goToPage: (page: number) => void
nextPage: () => void
prevPage: () => void
// Handle selection - just updates the index, scrolling is automatic
handleSelectionChange: (
newIndex: number,
setSelectedIndex: (index: number) => void,
) => void
// Page navigation - returns false for continuous scrolling (not needed)
handlePageNavigation: (
direction: 'left' | 'right',
setSelectedIndex: (index: number) => void,
) => boolean
// Scroll position info for UI display
scrollPosition: {
current: number
total: number
canScrollUp: boolean
canScrollDown: boolean
}
}
export function usePagination<T>({
totalItems,
maxVisible = DEFAULT_MAX_VISIBLE,
selectedIndex = 0,
}: UsePaginationOptions): UsePaginationResult<T> {
const needsPagination = totalItems > maxVisible
// Use a ref to track the previous scroll offset for smooth scrolling
const scrollOffsetRef = useRef(0)
// Compute the scroll offset based on selectedIndex
// This ensures the selected item is always visible
const scrollOffset = useMemo(() => {
if (!needsPagination) return 0
const prevOffset = scrollOffsetRef.current
// If selected item is above the visible window, scroll up
if (selectedIndex < prevOffset) {
scrollOffsetRef.current = selectedIndex
return selectedIndex
}
// If selected item is below the visible window, scroll down
if (selectedIndex >= prevOffset + maxVisible) {
const newOffset = selectedIndex - maxVisible + 1
scrollOffsetRef.current = newOffset
return newOffset
}
// Selected item is within visible window, keep current offset
// But ensure offset is still valid
const maxOffset = Math.max(0, totalItems - maxVisible)
const clampedOffset = Math.min(prevOffset, maxOffset)
scrollOffsetRef.current = clampedOffset
return clampedOffset
}, [selectedIndex, maxVisible, needsPagination, totalItems])
const startIndex = scrollOffset
const endIndex = Math.min(scrollOffset + maxVisible, totalItems)
const getVisibleItems = useCallback(
(items: T[]): T[] => {
if (!needsPagination) return items
return items.slice(startIndex, endIndex)
},
[needsPagination, startIndex, endIndex],
)
const toActualIndex = useCallback(
(visibleIndex: number): number => {
return startIndex + visibleIndex
},
[startIndex],
)
const isOnCurrentPage = useCallback(
(actualIndex: number): boolean => {
return actualIndex >= startIndex && actualIndex < endIndex
},
[startIndex, endIndex],
)
// These are mostly no-ops for continuous scrolling but kept for API compatibility
const goToPage = useCallback((_page: number) => {
// No-op - scrolling is controlled by selectedIndex
}, [])
const nextPage = useCallback(() => {
// No-op - scrolling is controlled by selectedIndex
}, [])
const prevPage = useCallback(() => {
// No-op - scrolling is controlled by selectedIndex
}, [])
// Simple selection handler - just updates the index
// Scrolling happens automatically via the useMemo above
const handleSelectionChange = useCallback(
(newIndex: number, setSelectedIndex: (index: number) => void) => {
const clampedIndex = Math.max(0, Math.min(newIndex, totalItems - 1))
setSelectedIndex(clampedIndex)
},
[totalItems],
)
// Page navigation - disabled for continuous scrolling
const handlePageNavigation = useCallback(
(
_direction: 'left' | 'right',
_setSelectedIndex: (index: number) => void,
): boolean => {
return false
},
[],
)
// Calculate page-like values for backwards compatibility
const totalPages = Math.max(1, Math.ceil(totalItems / maxVisible))
const currentPage = Math.floor(scrollOffset / maxVisible)
return {
currentPage,
totalPages,
startIndex,
endIndex,
needsPagination,
pageSize: maxVisible,
getVisibleItems,
toActualIndex,
isOnCurrentPage,
goToPage,
nextPage,
prevPage,
handleSelectionChange,
handlePageNavigation,
scrollPosition: {
current: selectedIndex + 1,
total: totalItems,
canScrollUp: scrollOffset > 0,
canScrollDown: scrollOffset + maxVisible < totalItems,
},
}
}

View File

@@ -0,0 +1,19 @@
import type { Command } from '../../commands.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'
const rateLimitOptions = {
type: 'local-jsx',
name: 'rate-limit-options',
description: 'Show options when rate limit is reached',
isEnabled: () => {
if (!isClaudeAISubscriber()) {
return false
}
return true
},
isHidden: true, // Hidden from help - only used internally
load: () => import('./rate-limit-options.js'),
} satisfies Command
export default rateLimitOptions

View File

@@ -0,0 +1,11 @@
import type { Command } from '../../commands.js'
const releaseNotes: Command = {
description: 'View release notes',
name: 'release-notes',
type: 'local',
supportsNonInteractive: true,
load: () => import('./release-notes.js'),
}
export default releaseNotes

View File

@@ -0,0 +1,50 @@
import type { LocalCommandResult } from '../../types/command.js'
import {
CHANGELOG_URL,
fetchAndStoreChangelog,
getAllReleaseNotes,
getStoredChangelog,
} from '../../utils/releaseNotes.js'
function formatReleaseNotes(notes: Array<[string, string[]]>): string {
return notes
.map(([version, notes]) => {
const header = `Version ${version}:`
const bulletPoints = notes.map(note => `· ${note}`).join('\n')
return `${header}\n${bulletPoints}`
})
.join('\n\n')
}
export async function call(): Promise<LocalCommandResult> {
// Try to fetch the latest changelog with a 500ms timeout
let freshNotes: Array<[string, string[]]> = []
try {
const timeoutPromise = new Promise<void>((_, reject) => {
setTimeout(rej => rej(new Error('Timeout')), 500, reject)
})
await Promise.race([fetchAndStoreChangelog(), timeoutPromise])
freshNotes = getAllReleaseNotes(await getStoredChangelog())
} catch {
// Either fetch failed or timed out - just use cached notes
}
// If we have fresh notes from the quick fetch, use those
if (freshNotes.length > 0) {
return { type: 'text', value: formatReleaseNotes(freshNotes) }
}
// Otherwise check cached notes
const cachedNotes = getAllReleaseNotes(await getStoredChangelog())
if (cachedNotes.length > 0) {
return { type: 'text', value: formatReleaseNotes(cachedNotes) }
}
// Nothing available, show link
return {
type: 'text',
value: `See the full changelog at: ${CHANGELOG_URL}`,
}
}

View File

@@ -0,0 +1,18 @@
/**
* /reload-plugins — Layer-3 refresh. Applies pending plugin changes to the
* running session. Implementation lazy-loaded.
*/
import type { Command } from '../../commands.js'
const reloadPlugins = {
type: 'local',
name: 'reload-plugins',
description: 'Activate pending plugin changes in the current session',
// SDK callers use query.reloadPlugins() (control request) instead of
// sending this as a text prompt — that returns structured data
// (commands, agents, plugins, mcpServers) for UI updates.
supportsNonInteractive: false,
load: () => import('./reload-plugins.js'),
} satisfies Command
export default reloadPlugins

View File

@@ -0,0 +1,61 @@
import { feature } from 'bun:bundle'
import { getIsRemoteMode } from '../../bootstrap/state.js'
import { redownloadUserSettings } from '../../services/settingsSync/index.js'
import type { LocalCommandCall } from '../../types/command.js'
import { isEnvTruthy } from '../../utils/envUtils.js'
import { refreshActivePlugins } from '../../utils/plugins/refresh.js'
import { settingsChangeDetector } from '../../utils/settings/changeDetector.js'
import { plural } from '../../utils/stringUtils.js'
export const call: LocalCommandCall = async (_args, context) => {
// CCR: re-pull user settings before the cache sweep so enabledPlugins /
// extraKnownMarketplaces pushed from the user's local CLI (settingsSync)
// take effect. Non-CCR headless (e.g. vscode SDK subprocess) shares disk
// with whoever writes settings — the file watcher delivers changes, no
// re-pull needed there.
//
// Managed settings intentionally NOT re-fetched: it already polls hourly
// (POLLING_INTERVAL_MS), and policy enforcement is eventually-consistent
// by design (stale-cache fallback on fetch failure). Interactive
// /reload-plugins has never re-fetched it either.
//
// No retries: user-initiated command, one attempt + fail-open. The user
// can re-run /reload-plugins to retry. Startup path keeps its retries.
if (
feature('DOWNLOAD_USER_SETTINGS') &&
(isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || getIsRemoteMode())
) {
const applied = await redownloadUserSettings()
// applyRemoteEntriesToLocal uses markInternalWrite to suppress the
// file watcher (correct for startup, nothing listening yet); fire
// notifyChange here so mid-session applySettingsChange runs.
if (applied) {
settingsChangeDetector.notifyChange('userSettings')
}
}
const r = await refreshActivePlugins(context.setAppState)
const parts = [
n(r.enabled_count, 'plugin'),
n(r.command_count, 'skill'),
n(r.agent_count, 'agent'),
n(r.hook_count, 'hook'),
// "plugin MCP/LSP" disambiguates from user-config/built-in servers,
// which /reload-plugins doesn't touch. Commands/hooks are plugin-only;
// agent_count is total agents (incl. built-ins). (gh-31321)
n(r.mcp_count, 'plugin MCP server'),
n(r.lsp_count, 'plugin LSP server'),
]
let msg = `Reloaded: ${parts.join(' · ')}`
if (r.error_count > 0) {
msg += `\n${n(r.error_count, 'error')} during load. Run /doctor for details.`
}
return { type: 'text', value: msg }
}
function n(count: number, noun: string): string {
return `${count} ${plural(count, noun)}`
}

View File

@@ -0,0 +1,15 @@
import type { Command } from '../../commands.js'
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
import { isClaudeAISubscriber } from '../../utils/auth.js'
export default {
type: 'local-jsx',
name: 'remote-env',
description: 'Configure the default remote environment for teleport sessions',
isEnabled: () =>
isClaudeAISubscriber() && isPolicyAllowed('allow_remote_sessions'),
get isHidden() {
return !isClaudeAISubscriber() || !isPolicyAllowed('allow_remote_sessions')
},
load: () => import('./remote-env.js'),
} satisfies Command

View File

@@ -0,0 +1,181 @@
import axios from 'axios'
import { getOauthConfig } from '../../constants/oauth.js'
import { logForDebugging } from '../../utils/debug.js'
import { getOAuthHeaders, prepareApiRequest } from '../../utils/teleport/api.js'
import { fetchEnvironments } from '../../utils/teleport/environments.js'
const CCR_BYOC_BETA_HEADER = 'ccr-byoc-2025-07-29'
/**
* 包装一个原始 GitHub 令牌,使其字符串表示被编辑。
* `String(token)`、模板字面量、`JSON.stringify(token)` 和任何
* 附加的错误消息将显示 `[REDACTED:gh-token]` 而不是
* 令牌值。仅在将原始值放入 HTTP body 的单个点调用 `.reveal()`。
*/
export class RedactedGithubToken {
readonly #value: string
constructor(raw: string) {
this.#value = raw
}
reveal(): string {
return this.#value
}
toString(): string {
return '[REDACTED:gh-token]'
}
toJSON(): string {
return '[REDACTED:gh-token]'
}
[Symbol.for('nodejs.util.inspect.custom')](): string {
return '[REDACTED:gh-token]'
}
}
export type ImportTokenResult = {
github_username: string
}
export type ImportTokenError =
| { kind: 'not_signed_in' }
| { kind: 'invalid_token' }
| { kind: 'server'; status: number }
| { kind: 'network' }
/**
* 将 GitHub 令牌 POST 到 CCR 后端,后端针对
* GitHub 的 /user 端点进行验证,并将其 Fernet 加密存储在 sync_user_tokens 中。
* 存储的令牌满足与 OAuth 令牌相同的读取路径,因此在成功后会
* clone/push 在 claude.ai/code 中立即生效。
*/
export async function importGithubToken(
token: RedactedGithubToken,
): Promise<
| { ok: true; result: ImportTokenResult }
| { ok: false; error: ImportTokenError }
> {
let accessToken: string, orgUUID: string
try {
;({ accessToken, orgUUID } = await prepareApiRequest())
} catch {
return { ok: false, error: { kind: 'not_signed_in' } }
}
const url = `${getOauthConfig().BASE_API_URL}/v1/code/github/import-token`
const headers = {
...getOAuthHeaders(accessToken),
'anthropic-beta': CCR_BYOC_BETA_HEADER,
'x-organization-uuid': orgUUID,
}
try {
const response = await axios.post<ImportTokenResult>(
url,
{ token: token.reveal() },
{ headers, timeout: 15000, validateStatus: () => true },
)
if (response.status === 200) {
return { ok: true, result: response.data }
}
if (response.status === 400) {
return { ok: false, error: { kind: 'invalid_token' } }
}
if (response.status === 401) {
return { ok: false, error: { kind: 'not_signed_in' } }
}
logForDebugging(`import-token returned ${response.status}`, {
level: 'error',
})
return { ok: false, error: { kind: 'server', status: response.status } }
} catch (err) {
if (axios.isAxiosError(err)) {
// err.config.data 会包含带有原始令牌的 POST body。
// 不要在任何日志中包含它。错误代码本身就足够了。
logForDebugging(`import-token network error: ${err.code ?? 'unknown'}`, {
level: 'error',
})
}
return { ok: false, error: { kind: 'network' } }
}
}
async function hasExistingEnvironment(): Promise<boolean> {
try {
const envs = await fetchEnvironments()
return envs.length > 0
} catch {
return false
}
}
/**
* 尽最大努力创建默认环境。镜像 Web 入职的
* DEFAULT_CLOUD_ENVIRONMENT_REQUEST因此首次用户落在
* composer 而不是 env-setup 上。首先检查现有环境
* 以便重新运行 /web-setup 不会堆积重复项。失败是
* 非致命的 — 令牌导入已经成功Web 状态
* 机器在下一次加载时回退到 env-setup。
*/
export async function createDefaultEnvironment(): Promise<boolean> {
let accessToken: string, orgUUID: string
try {
;({ accessToken, orgUUID } = await prepareApiRequest())
} catch {
return false
}
if (await hasExistingEnvironment()) {
return true
}
// /private/organizations/{org}/ 路径拒绝 CLI OAuth 令牌(错误的
// auth dep。公共路径使用 build_flexible_auth — 与
// fetchEnvironments() 使用的相同路径。Org 通过 x-organization-uuid header 传递。
const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers/cloud/create`
const headers = {
...getOAuthHeaders(accessToken),
'x-organization-uuid': orgUUID,
}
try {
const response = await axios.post(
url,
{
name: 'Default',
kind: 'anthropic_cloud',
description: 'Default - trusted network access',
config: {
environment_type: 'anthropic',
cwd: '/home/user',
init_script: null,
environment: {},
languages: [
{ name: 'python', version: '3.11' },
{ name: 'node', version: '20' },
],
network_config: {
allowed_hosts: [],
allow_default_hosts: true,
},
},
},
{ headers, timeout: 15000, validateStatus: () => true },
)
return response.status >= 200 && response.status < 300
} catch {
return false
}
}
/** 当用户拥有有效的 Claude OAuth 凭据时返回 true。 */
export async function isSignedIn(): Promise<boolean> {
try {
await prepareApiRequest()
return true
} catch {
return false
}
}
export function getCodeWebUrl(): string {
return `${getOauthConfig().CLAUDE_AI_ORIGIN}/code`
}

View File

@@ -0,0 +1,20 @@
import type { Command } from '../../commands.js'
import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'
import { isPolicyAllowed } from '../../services/policyLimits/index.js'
const web = {
type: 'local-jsx',
name: 'web-setup',
description:
'Setup Claude Code on the web (requires connecting your GitHub account)',
availability: ['claude-ai'],
isEnabled: () =>
getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) &&
isPolicyAllowed('allow_remote_sessions'),
get isHidden() {
return !isPolicyAllowed('allow_remote_sessions')
},
load: () => import('./remote-setup.js'),
} satisfies Command
export default web

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