Compare commits
4 Commits
4eb053eef5
...
9e7338d54c
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e7338d54c | |||
| d04ef7e701 | |||
| 3606a51288 | |||
| 2cf6c23fdc |
19
README.md
19
README.md
@@ -33,3 +33,22 @@ bun run compile
|
|||||||
|
|
||||||
- `node_modules/`, `dist/`, and generated CLI binaries are ignored by Git.
|
- `node_modules/`, `dist/`, and generated CLI binaries are ignored by Git.
|
||||||
- `bun.lock` is kept in the repository for reproducible installs.
|
- `bun.lock` is kept in the repository for reproducible installs.
|
||||||
|
|
||||||
|
## Local Info Egress Status
|
||||||
|
|
||||||
|
This fork has removed several local system and project metadata egress paths that existed in the recovered upstream code.
|
||||||
|
|
||||||
|
Removed in this repository:
|
||||||
|
|
||||||
|
- Model-request context injection of working directory, git status/history, `CLAUDE.md`, current date, platform, shell, and OS version.
|
||||||
|
- Feedback upload and transcript-share upload paths.
|
||||||
|
- Remote Control / Bridge registration fields that sent machine name, git branch, and git repository URL, plus git source/outcome data in bridge session creation.
|
||||||
|
- Trusted-device enrollment and trusted-device token header emission for bridge requests.
|
||||||
|
- `/insights` automatic S3 upload; reports now stay local via `file://` paths only.
|
||||||
|
|
||||||
|
Still present:
|
||||||
|
|
||||||
|
- Normal Claude API requests are still part of product functionality; this fork only removes extra local metadata injection, not core model/network access.
|
||||||
|
- Datadog and Anthropic 1P analytics codepaths still exist and can emit environment/process metadata unless disabled by runtime privacy settings.
|
||||||
|
- GrowthBook remote evaluation still exists and still prepares remote-eval user attributes.
|
||||||
|
- Optional OpenTelemetry export still exists behind telemetry configuration flags.
|
||||||
|
|||||||
@@ -154,10 +154,6 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
}>(
|
}>(
|
||||||
`${deps.baseUrl}/v1/environments/bridge`,
|
`${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
|
// Advertise session capacity so claude.ai/code can show
|
||||||
// "2/4 sessions" badges and only block the picker when
|
// "2/4 sessions" badges and only block the picker when
|
||||||
// actually at capacity. Backends that don't yet accept
|
// actually at capacity. Backends that don't yet accept
|
||||||
@@ -190,7 +186,7 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient {
|
|||||||
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
`[bridge:api] POST /v1/environments/bridge -> ${response.status} environment_id=${response.data.environment_id}`,
|
||||||
)
|
)
|
||||||
debug(
|
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 } })}`,
|
`[bridge:api] >>> ${debugBody({ max_sessions: config.maxSessions, metadata: { worker_type: config.workerType } })}`,
|
||||||
)
|
)
|
||||||
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
debug(`[bridge:api] <<< ${debugBody(response.data)}`)
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { randomUUID } from 'crypto'
|
import { randomUUID } from 'crypto'
|
||||||
import { hostname, tmpdir } from 'os'
|
import { tmpdir } from 'os'
|
||||||
import { basename, join, resolve } from 'path'
|
import { basename, join, resolve } from 'path'
|
||||||
import { getRemoteSessionUrl } from '../constants/product.js'
|
import { getRemoteSessionUrl } from '../constants/product.js'
|
||||||
import { shutdownDatadog } from '../services/analytics/datadog.js'
|
import { shutdownDatadog } from '../services/analytics/datadog.js'
|
||||||
@@ -2203,9 +2203,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||||
: baseUrl
|
: baseUrl
|
||||||
|
|
||||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
const { findGitRoot } = await import('../utils/git.js')
|
||||||
'../utils/git.js'
|
|
||||||
)
|
|
||||||
|
|
||||||
// Precheck worktree availability for the first-run dialog and the `w`
|
// Precheck worktree availability for the first-run dialog and the `w`
|
||||||
// toggle. Unconditional so we know upfront whether worktree is an option.
|
// toggle. Unconditional so we know upfront whether worktree is an option.
|
||||||
@@ -2337,9 +2335,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = await getBranch()
|
|
||||||
const gitRepoUrl = await getRemoteUrl()
|
|
||||||
const machineName = hostname()
|
|
||||||
const bridgeId = randomUUID()
|
const bridgeId = randomUUID()
|
||||||
|
|
||||||
const { handleOAuth401Error } = await import('../utils/auth.js')
|
const { handleOAuth401Error } = await import('../utils/auth.js')
|
||||||
@@ -2417,9 +2412,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
|
|
||||||
const config: BridgeConfig = {
|
const config: BridgeConfig = {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
maxSessions,
|
maxSessions,
|
||||||
spawnMode,
|
spawnMode,
|
||||||
verbose,
|
verbose,
|
||||||
@@ -2435,7 +2427,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir} branch=${branch} gitRepoUrl=${gitRepoUrl} machine=${machineName}`,
|
`[bridge:init] bridgeId=${bridgeId}${reuseEnvironmentId ? ` reuseEnvironmentId=${reuseEnvironmentId}` : ''} dir=${dir}`,
|
||||||
)
|
)
|
||||||
logForDebugging(
|
logForDebugging(
|
||||||
`[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`,
|
`[bridge:init] apiBaseUrl=${baseUrl} sessionIngressUrl=${sessionIngressUrl}`,
|
||||||
@@ -2591,11 +2583,7 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const logger = createBridgeLogger({ verbose })
|
const logger = createBridgeLogger({ verbose })
|
||||||
const { parseGitHubRepository } = await import('../utils/detectRepository.js')
|
logger.setRepoInfo(basename(dir), '')
|
||||||
const ownerRepo = gitRepoUrl ? parseGitHubRepository(gitRepoUrl) : null
|
|
||||||
// Use the repo name from the parsed owner/repo, or fall back to the dir basename
|
|
||||||
const repoName = ownerRepo ? ownerRepo.split('/').pop()! : basename(dir)
|
|
||||||
logger.setRepoInfo(repoName, branch)
|
|
||||||
|
|
||||||
// `w` toggle is available iff we're in a multi-session mode AND worktree
|
// `w` toggle is available iff we're in a multi-session mode AND worktree
|
||||||
// is a valid option. When unavailable, the mode suffix and hint are hidden.
|
// is a valid option. When unavailable, the mode suffix and hint are hidden.
|
||||||
@@ -2678,8 +2666,6 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
|||||||
environmentId,
|
environmentId,
|
||||||
title: name,
|
title: name,
|
||||||
events: [],
|
events: [],
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
getAccessToken: getBridgeAccessToken,
|
getAccessToken: getBridgeAccessToken,
|
||||||
@@ -2856,9 +2842,7 @@ export async function runBridgeHeadless(
|
|||||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||||
: baseUrl
|
: baseUrl
|
||||||
|
|
||||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
const { findGitRoot } = await import('../utils/git.js')
|
||||||
'../utils/git.js'
|
|
||||||
)
|
|
||||||
const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
|
const { hasWorktreeCreateHook } = await import('../utils/hooks.js')
|
||||||
|
|
||||||
if (opts.spawnMode === 'worktree') {
|
if (opts.spawnMode === 'worktree') {
|
||||||
@@ -2871,16 +2855,10 @@ export async function runBridgeHeadless(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const branch = await getBranch()
|
|
||||||
const gitRepoUrl = await getRemoteUrl()
|
|
||||||
const machineName = hostname()
|
|
||||||
const bridgeId = randomUUID()
|
const bridgeId = randomUUID()
|
||||||
|
|
||||||
const config: BridgeConfig = {
|
const config: BridgeConfig = {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
maxSessions: opts.capacity,
|
maxSessions: opts.capacity,
|
||||||
spawnMode: opts.spawnMode,
|
spawnMode: opts.spawnMode,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
@@ -2934,8 +2912,6 @@ export async function runBridgeHeadless(
|
|||||||
environmentId,
|
environmentId,
|
||||||
title: opts.name,
|
title: opts.name,
|
||||||
events: [],
|
events: [],
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal,
|
signal,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
getAccessToken: opts.getAccessToken,
|
getAccessToken: opts.getAccessToken,
|
||||||
|
|||||||
@@ -4,17 +4,6 @@ import { errorMessage } from '../utils/errors.js'
|
|||||||
import { extractErrorDetail } from './debugUtils.js'
|
import { extractErrorDetail } from './debugUtils.js'
|
||||||
import { toCompatSessionId } from './sessionIdCompat.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[] }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
// Events must be wrapped in { type: 'event', data: <sdk_message> } for the
|
||||||
// POST /v1/sessions endpoint (discriminated union format).
|
// POST /v1/sessions endpoint (discriminated union format).
|
||||||
type SessionEvent = {
|
type SessionEvent = {
|
||||||
@@ -35,8 +24,6 @@ export async function createBridgeSession({
|
|||||||
environmentId,
|
environmentId,
|
||||||
title,
|
title,
|
||||||
events,
|
events,
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal,
|
signal,
|
||||||
baseUrl: baseUrlOverride,
|
baseUrl: baseUrlOverride,
|
||||||
getAccessToken,
|
getAccessToken,
|
||||||
@@ -45,8 +32,6 @@ export async function createBridgeSession({
|
|||||||
environmentId: string
|
environmentId: string
|
||||||
title?: string
|
title?: string
|
||||||
events: SessionEvent[]
|
events: SessionEvent[]
|
||||||
gitRepoUrl: string | null
|
|
||||||
branch: string
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
baseUrl?: string
|
baseUrl?: string
|
||||||
getAccessToken?: () => string | undefined
|
getAccessToken?: () => string | undefined
|
||||||
@@ -56,8 +41,6 @@ export async function createBridgeSession({
|
|||||||
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
const { getOrganizationUUID } = await import('../services/oauth/client.js')
|
||||||
const { getOauthConfig } = await import('../constants/oauth.js')
|
const { getOauthConfig } = await import('../constants/oauth.js')
|
||||||
const { getOAuthHeaders } = await import('../utils/teleport/api.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 { getMainLoopModel } = await import('../utils/model/model.js')
|
||||||
const { default: axios } = await import('axios')
|
const { default: axios } = await import('axios')
|
||||||
|
|
||||||
@@ -74,60 +57,12 @@ export async function createBridgeSession({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build git source and outcome context
|
|
||||||
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 {
|
|
||||||
// Fallback: try parseGitHubRepository for owner/repo format
|
|
||||||
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 = {
|
const requestBody = {
|
||||||
...(title !== undefined && { title }),
|
...(title !== undefined && { title }),
|
||||||
events,
|
events,
|
||||||
session_context: {
|
session_context: {
|
||||||
sources: gitSource ? [gitSource] : [],
|
sources: [],
|
||||||
outcomes: gitOutcome ? [gitOutcome] : [],
|
outcomes: [],
|
||||||
model: getMainLoopModel(),
|
model: getMainLoopModel(),
|
||||||
},
|
},
|
||||||
environment_id: environmentId,
|
environment_id: environmentId,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { feature } from 'bun:bundle'
|
import { feature } from 'bun:bundle'
|
||||||
import { hostname } from 'os'
|
|
||||||
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
|
import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'
|
||||||
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
import type { SDKMessage } from '../entrypoints/agentSdkTypes.ts'
|
||||||
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.ts'
|
import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.ts'
|
||||||
@@ -34,7 +33,6 @@ import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'
|
|||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
import { stripDisplayTagsAllowEmpty } from '../utils/displayTags.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
import { errorMessage } from '../utils/errors.js'
|
||||||
import { getBranch, getRemoteUrl } from '../utils/git.js'
|
|
||||||
import { toSDKMessages } from '../utils/messages/mappers.js'
|
import { toSDKMessages } from '../utils/messages/mappers.js'
|
||||||
import {
|
import {
|
||||||
getContentText,
|
getContentText,
|
||||||
@@ -460,10 +458,6 @@ export async function initReplBridge(
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gather git context — this is the bootstrap-read boundary.
|
|
||||||
// Everything from here down is passed explicitly to bridgeCore.
|
|
||||||
const branch = await getBranch()
|
|
||||||
const gitRepoUrl = await getRemoteUrl()
|
|
||||||
const sessionIngressUrl =
|
const sessionIngressUrl =
|
||||||
process.env.USER_TYPE === 'ant' &&
|
process.env.USER_TYPE === 'ant' &&
|
||||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||||
@@ -489,9 +483,6 @@ export async function initReplBridge(
|
|||||||
// so no adapter needed — just the narrower type on the way out.
|
// so no adapter needed — just the narrower type on the way out.
|
||||||
return initBridgeCore({
|
return initBridgeCore({
|
||||||
dir: getOriginalCwd(),
|
dir: getOriginalCwd(),
|
||||||
machineName: hostname(),
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
title,
|
title,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
sessionIngressUrl,
|
sessionIngressUrl,
|
||||||
|
|||||||
@@ -84,15 +84,12 @@ export type BridgeState = 'ready' | 'connected' | 'reconnecting' | 'failed'
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Explicit-param input to initBridgeCore. Everything initReplBridge reads
|
* Explicit-param input to initBridgeCore. Everything initReplBridge reads
|
||||||
* from bootstrap state (cwd, session ID, git, OAuth) becomes a field here.
|
* from bootstrap state (cwd, session ID, OAuth) becomes a field here.
|
||||||
* A daemon caller (Agent SDK, PR 4) that never runs main.tsx fills these
|
* A daemon caller (Agent SDK, PR 4) that never runs main.tsx fills these
|
||||||
* in itself.
|
* in itself.
|
||||||
*/
|
*/
|
||||||
export type BridgeCoreParams = {
|
export type BridgeCoreParams = {
|
||||||
dir: string
|
dir: string
|
||||||
machineName: string
|
|
||||||
branch: string
|
|
||||||
gitRepoUrl: string | null
|
|
||||||
title: string
|
title: string
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
sessionIngressUrl: string
|
sessionIngressUrl: string
|
||||||
@@ -113,14 +110,12 @@ export type BridgeCoreParams = {
|
|||||||
* Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
|
* Daemon wrapper passes `createBridgeSessionLean` from `sessionApi.ts`
|
||||||
* (HTTP-only, orgUUID+model supplied by the daemon caller).
|
* (HTTP-only, orgUUID+model supplied by the daemon caller).
|
||||||
*
|
*
|
||||||
* Receives `gitRepoUrl`+`branch` so the REPL wrapper can build the git
|
* Receives the registered environment ID and session title. Daemon callers
|
||||||
* source/outcome for claude.ai's session card. Daemon ignores them.
|
* may supply their own lean session-creation implementation.
|
||||||
*/
|
*/
|
||||||
createSession: (opts: {
|
createSession: (opts: {
|
||||||
environmentId: string
|
environmentId: string
|
||||||
title: string
|
title: string
|
||||||
gitRepoUrl: string | null
|
|
||||||
branch: string
|
|
||||||
signal: AbortSignal
|
signal: AbortSignal
|
||||||
}) => Promise<string | null>
|
}) => Promise<string | null>
|
||||||
/**
|
/**
|
||||||
@@ -262,9 +257,6 @@ export async function initBridgeCore(
|
|||||||
): Promise<BridgeCoreHandle | null> {
|
): Promise<BridgeCoreHandle | null> {
|
||||||
const {
|
const {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
title,
|
title,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
sessionIngressUrl,
|
sessionIngressUrl,
|
||||||
@@ -331,9 +323,6 @@ export async function initBridgeCore(
|
|||||||
|
|
||||||
const bridgeConfig: BridgeConfig = {
|
const bridgeConfig: BridgeConfig = {
|
||||||
dir,
|
dir,
|
||||||
machineName,
|
|
||||||
branch,
|
|
||||||
gitRepoUrl,
|
|
||||||
maxSessions: 1,
|
maxSessions: 1,
|
||||||
spawnMode: 'single-session',
|
spawnMode: 'single-session',
|
||||||
verbose: false,
|
verbose: false,
|
||||||
@@ -457,8 +446,6 @@ export async function initBridgeCore(
|
|||||||
const createdSessionId = await createSession({
|
const createdSessionId = await createSession({
|
||||||
environmentId,
|
environmentId,
|
||||||
title,
|
title,
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -764,8 +751,6 @@ export async function initBridgeCore(
|
|||||||
const newSessionId = await createSession({
|
const newSessionId = await createSession({
|
||||||
environmentId,
|
environmentId,
|
||||||
title: currentTitle,
|
title: currentTitle,
|
||||||
gitRepoUrl,
|
|
||||||
branch,
|
|
||||||
signal: AbortSignal.timeout(15_000),
|
signal: AbortSignal.timeout(15_000),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,5 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import memoize from 'lodash-es/memoize.js'
|
|
||||||
import { hostname } from 'os'
|
|
||||||
import { getOauthConfig } from '../constants/oauth.js'
|
|
||||||
import {
|
|
||||||
checkGate_CACHED_OR_BLOCKING,
|
|
||||||
getFeatureValue_CACHED_MAY_BE_STALE,
|
|
||||||
} from '../services/analytics/growthbook.js'
|
|
||||||
import { logForDebugging } from '../utils/debug.js'
|
import { logForDebugging } from '../utils/debug.js'
|
||||||
import { errorMessage } from '../utils/errors.js'
|
|
||||||
import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'
|
|
||||||
import { getSecureStorage } from '../utils/secureStorage/index.js'
|
import { getSecureStorage } from '../utils/secureStorage/index.js'
|
||||||
import { jsonStringify } from '../utils/slowOperations.js'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trusted device token source for bridge (remote-control) sessions.
|
* Trusted device token source for bridge (remote-control) sessions.
|
||||||
@@ -30,49 +19,20 @@ import { jsonStringify } from '../utils/slowOperations.js'
|
|||||||
* #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
|
* #295987 (B2 Python routes), #307150 (C1' CCR v2 gate).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const TRUSTED_DEVICE_GATE = 'tengu_sessions_elevated_auth_enforcement'
|
|
||||||
|
|
||||||
function isGateEnabled(): boolean {
|
|
||||||
return getFeatureValue_CACHED_MAY_BE_STALE(TRUSTED_DEVICE_GATE, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memoized — secureStorage.read() spawns a macOS `security` subprocess (~40ms).
|
|
||||||
// bridgeApi.ts calls this from getHeaders() on every poll/heartbeat/ack.
|
|
||||||
// Cache cleared after enrollment (below) and on logout (clearAuthRelatedCaches).
|
|
||||||
//
|
|
||||||
// Only the storage read is memoized — the GrowthBook gate is checked live so
|
|
||||||
// that a gate flip after GrowthBook refresh takes effect without a restart.
|
|
||||||
const readStoredToken = memoize((): string | undefined => {
|
|
||||||
// Env var takes precedence for testing/canary.
|
|
||||||
const envToken = process.env.CLAUDE_TRUSTED_DEVICE_TOKEN
|
|
||||||
if (envToken) {
|
|
||||||
return envToken
|
|
||||||
}
|
|
||||||
return getSecureStorage().read()?.trustedDeviceToken
|
|
||||||
})
|
|
||||||
|
|
||||||
export function getTrustedDeviceToken(): string | undefined {
|
export function getTrustedDeviceToken(): string | undefined {
|
||||||
if (!isGateEnabled()) {
|
return undefined
|
||||||
return undefined
|
|
||||||
}
|
|
||||||
return readStoredToken()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearTrustedDeviceTokenCache(): void {
|
export function clearTrustedDeviceTokenCache(): void {
|
||||||
readStoredToken.cache?.clear?.()
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the stored trusted device token from secure storage and the memo cache.
|
* Clear the stored trusted device token from secure storage and the memo cache.
|
||||||
* Called before enrollTrustedDevice() during /login so a stale token from the
|
* Called during /login so a stale token from the previous account isn't sent
|
||||||
* previous account isn't sent as X-Trusted-Device-Token while enrollment is
|
* as X-Trusted-Device-Token after account switches.
|
||||||
* in-flight (enrollTrustedDevice is async — bridge API calls between login and
|
|
||||||
* enrollment completion would otherwise still read the old cached token).
|
|
||||||
*/
|
*/
|
||||||
export function clearTrustedDeviceToken(): void {
|
export function clearTrustedDeviceToken(): void {
|
||||||
if (!isGateEnabled()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const secureStorage = getSecureStorage()
|
const secureStorage = getSecureStorage()
|
||||||
try {
|
try {
|
||||||
const data = secureStorage.read()
|
const data = secureStorage.read()
|
||||||
@@ -83,128 +43,14 @@ export function clearTrustedDeviceToken(): void {
|
|||||||
} catch {
|
} catch {
|
||||||
// Best-effort — don't block login if storage is inaccessible
|
// Best-effort — don't block login if storage is inaccessible
|
||||||
}
|
}
|
||||||
readStoredToken.cache?.clear?.()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enroll this device via POST /auth/trusted_devices and persist the token
|
* Trusted-device enrollment is disabled in this build. Keep the no-op entry
|
||||||
* to keychain. Best-effort — logs and returns on failure so callers
|
* point so callers can continue to invoke it without branching.
|
||||||
* (post-login hooks) don't block the login flow.
|
|
||||||
*
|
|
||||||
* The server gates enrollment on account_session.created_at < 10min, so
|
|
||||||
* this must be called immediately after a fresh /login. Calling it later
|
|
||||||
* (e.g. lazy enrollment on /bridge 403) will fail with 403 stale_session.
|
|
||||||
*/
|
*/
|
||||||
export async function enrollTrustedDevice(): Promise<void> {
|
export async function enrollTrustedDevice(): Promise<void> {
|
||||||
try {
|
logForDebugging(
|
||||||
// checkGate_CACHED_OR_BLOCKING awaits any in-flight GrowthBook re-init
|
'[trusted-device] Enrollment disabled in this build; skipping trusted device registration',
|
||||||
// (triggered by refreshGrowthBookAfterAuthChange in login.tsx) before
|
)
|
||||||
// reading the gate, so we get the post-refresh value.
|
|
||||||
if (!(await checkGate_CACHED_OR_BLOCKING(TRUSTED_DEVICE_GATE))) {
|
|
||||||
logForDebugging(
|
|
||||||
`[trusted-device] Gate ${TRUSTED_DEVICE_GATE} is off, skipping enrollment`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// If CLAUDE_TRUSTED_DEVICE_TOKEN is set (e.g. by an enterprise wrapper),
|
|
||||||
// skip enrollment — the env var takes precedence in readStoredToken() so
|
|
||||||
// any enrolled token would be shadowed and never used.
|
|
||||||
if (process.env.CLAUDE_TRUSTED_DEVICE_TOKEN) {
|
|
||||||
logForDebugging(
|
|
||||||
'[trusted-device] CLAUDE_TRUSTED_DEVICE_TOKEN env var is set, skipping enrollment (env var takes precedence)',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Lazy require — utils/auth.ts transitively pulls ~1300 modules
|
|
||||||
// (config → file → permissions → sessionStorage → commands). Daemon callers
|
|
||||||
// of getTrustedDeviceToken() don't need this; only /login does.
|
|
||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
||||||
const { getClaudeAIOAuthTokens } =
|
|
||||||
require('../utils/auth.js') as typeof import('../utils/auth.js')
|
|
||||||
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
||||||
const accessToken = getClaudeAIOAuthTokens()?.accessToken
|
|
||||||
if (!accessToken) {
|
|
||||||
logForDebugging('[trusted-device] No OAuth token, skipping enrollment')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Always re-enroll on /login — the existing token may belong to a
|
|
||||||
// different account (account-switch without /logout). Skipping enrollment
|
|
||||||
// would send the old account's token on the new account's bridge calls.
|
|
||||||
const secureStorage = getSecureStorage()
|
|
||||||
|
|
||||||
if (isEssentialTrafficOnly()) {
|
|
||||||
logForDebugging(
|
|
||||||
'[trusted-device] Essential traffic only, skipping enrollment',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseUrl = getOauthConfig().BASE_API_URL
|
|
||||||
let response
|
|
||||||
try {
|
|
||||||
response = await axios.post<{
|
|
||||||
device_token?: string
|
|
||||||
device_id?: string
|
|
||||||
}>(
|
|
||||||
`${baseUrl}/api/auth/trusted_devices`,
|
|
||||||
{ display_name: `Claude Code on ${hostname()} · ${process.platform}` },
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${accessToken}`,
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
timeout: 10_000,
|
|
||||||
validateStatus: s => s < 500,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
logForDebugging(
|
|
||||||
`[trusted-device] Enrollment request failed: ${errorMessage(err)}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.status !== 200 && response.status !== 201) {
|
|
||||||
logForDebugging(
|
|
||||||
`[trusted-device] Enrollment failed ${response.status}: ${jsonStringify(response.data).slice(0, 200)}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = response.data?.device_token
|
|
||||||
if (!token || typeof token !== 'string') {
|
|
||||||
logForDebugging(
|
|
||||||
'[trusted-device] Enrollment response missing device_token field',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const storageData = secureStorage.read()
|
|
||||||
if (!storageData) {
|
|
||||||
logForDebugging(
|
|
||||||
'[trusted-device] Cannot read storage, skipping token persist',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
storageData.trustedDeviceToken = token
|
|
||||||
const result = secureStorage.update(storageData)
|
|
||||||
if (!result.success) {
|
|
||||||
logForDebugging(
|
|
||||||
`[trusted-device] Failed to persist token: ${result.warning ?? 'unknown'}`,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
readStoredToken.cache?.clear?.()
|
|
||||||
logForDebugging(
|
|
||||||
`[trusted-device] Enrolled device_id=${response.data.device_id ?? 'unknown'}`,
|
|
||||||
)
|
|
||||||
} catch (err: unknown) {
|
|
||||||
logForDebugging(
|
|
||||||
`[trusted-device] Storage write failed: ${errorMessage(err)}`,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch (err: unknown) {
|
|
||||||
logForDebugging(`[trusted-device] Enrollment error: ${errorMessage(err)}`)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,9 +80,6 @@ export type BridgeWorkerType = 'claude_code' | 'claude_code_assistant'
|
|||||||
|
|
||||||
export type BridgeConfig = {
|
export type BridgeConfig = {
|
||||||
dir: string
|
dir: string
|
||||||
machineName: string
|
|
||||||
branch: string
|
|
||||||
gitRepoUrl: string | null
|
|
||||||
maxSessions: number
|
maxSessions: number
|
||||||
spawnMode: SpawnMode
|
spawnMode: SpawnMode
|
||||||
verbose: boolean
|
verbose: boolean
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { execFileSync } from 'child_process'
|
|
||||||
import { diffLines } from 'diff'
|
import { diffLines } from 'diff'
|
||||||
import { constants as fsConstants } from 'fs'
|
import { constants as fsConstants } from 'fs'
|
||||||
import {
|
import {
|
||||||
@@ -2674,7 +2673,7 @@ export type InsightsExport = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build export data from already-computed values.
|
* Build export data from already-computed values.
|
||||||
* Used by background upload to S3.
|
* Used by the local report writer.
|
||||||
*/
|
*/
|
||||||
export function buildExportData(
|
export function buildExportData(
|
||||||
data: AggregatedData,
|
data: AggregatedData,
|
||||||
@@ -3069,35 +3068,8 @@ const usageReport: Command = {
|
|||||||
{ collectRemote },
|
{ collectRemote },
|
||||||
)
|
)
|
||||||
|
|
||||||
let reportUrl = `file://${htmlPath}`
|
const reportUrl = `file://${htmlPath}`
|
||||||
let uploadHint = ''
|
const uploadHint = ''
|
||||||
|
|
||||||
if (process.env.USER_TYPE === 'ant') {
|
|
||||||
// Try to upload to S3
|
|
||||||
const timestamp = new Date()
|
|
||||||
.toISOString()
|
|
||||||
.replace(/[-:]/g, '')
|
|
||||||
.replace('T', '_')
|
|
||||||
.slice(0, 15)
|
|
||||||
const username = process.env.SAFEUSER || process.env.USER || 'unknown'
|
|
||||||
const filename = `${username}_insights_${timestamp}.html`
|
|
||||||
const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}`
|
|
||||||
const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}`
|
|
||||||
|
|
||||||
reportUrl = s3Url
|
|
||||||
try {
|
|
||||||
execFileSync('ff', ['cp', htmlPath, s3Path], {
|
|
||||||
timeout: 60000,
|
|
||||||
stdio: 'pipe', // Suppress output
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// Upload failed - fall back to local file and show upload command
|
|
||||||
reportUrl = `file://${htmlPath}`
|
|
||||||
uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`.
|
|
||||||
To share, run: ff cp ${htmlPath} ${s3Path}
|
|
||||||
Then access at: ${s3Url}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build header with stats
|
// Build header with stats
|
||||||
const sessionLabel =
|
const sessionLabel =
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,23 +1,9 @@
|
|||||||
import axios from 'axios'
|
|
||||||
import { readFile, stat } from 'fs/promises'
|
|
||||||
import type { Message } from '../../types/message.js'
|
import type { Message } from '../../types/message.js'
|
||||||
import { checkAndRefreshOAuthTokenIfNeeded } from '../../utils/auth.js'
|
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
|
||||||
import { errorMessage } from '../../utils/errors.js'
|
|
||||||
import { getAuthHeaders, getUserAgent } from '../../utils/http.js'
|
|
||||||
import { normalizeMessagesForAPI } from '../../utils/messages.js'
|
|
||||||
import {
|
|
||||||
extractAgentIdsFromMessages,
|
|
||||||
getTranscriptPath,
|
|
||||||
loadSubagentTranscripts,
|
|
||||||
MAX_TRANSCRIPT_READ_BYTES,
|
|
||||||
} from '../../utils/sessionStorage.js'
|
|
||||||
import { jsonStringify } from '../../utils/slowOperations.js'
|
|
||||||
import { redactSensitiveInfo } from '../Feedback.js'
|
|
||||||
|
|
||||||
type TranscriptShareResult = {
|
type TranscriptShareResult = {
|
||||||
success: boolean
|
success: boolean
|
||||||
transcriptId?: string
|
transcriptId?: string
|
||||||
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TranscriptShareTrigger =
|
export type TranscriptShareTrigger =
|
||||||
@@ -27,86 +13,12 @@ export type TranscriptShareTrigger =
|
|||||||
| 'memory_survey'
|
| 'memory_survey'
|
||||||
|
|
||||||
export async function submitTranscriptShare(
|
export async function submitTranscriptShare(
|
||||||
messages: Message[],
|
_messages: Message[],
|
||||||
trigger: TranscriptShareTrigger,
|
_trigger: TranscriptShareTrigger,
|
||||||
appearanceId: string,
|
_appearanceId: string,
|
||||||
): Promise<TranscriptShareResult> {
|
): Promise<TranscriptShareResult> {
|
||||||
try {
|
return {
|
||||||
logForDebugging('Collecting transcript for sharing', { level: 'info' })
|
success: false,
|
||||||
|
disabled: true,
|
||||||
const transcript = normalizeMessagesForAPI(messages)
|
|
||||||
|
|
||||||
// Collect subagent transcripts
|
|
||||||
const agentIds = extractAgentIdsFromMessages(messages)
|
|
||||||
const subagentTranscripts = await loadSubagentTranscripts(agentIds)
|
|
||||||
|
|
||||||
// Read raw JSONL transcript (with size guard to prevent OOM)
|
|
||||||
let rawTranscriptJsonl: string | undefined
|
|
||||||
try {
|
|
||||||
const transcriptPath = getTranscriptPath()
|
|
||||||
const { size } = await stat(transcriptPath)
|
|
||||||
if (size <= MAX_TRANSCRIPT_READ_BYTES) {
|
|
||||||
rawTranscriptJsonl = await readFile(transcriptPath, 'utf-8')
|
|
||||||
} else {
|
|
||||||
logForDebugging(
|
|
||||||
`Skipping raw transcript read: file too large (${size} bytes)`,
|
|
||||||
{ level: 'warn' },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = {
|
|
||||||
trigger,
|
|
||||||
version: MACRO.VERSION,
|
|
||||||
platform: process.platform,
|
|
||||||
transcript,
|
|
||||||
subagentTranscripts:
|
|
||||||
Object.keys(subagentTranscripts).length > 0
|
|
||||||
? subagentTranscripts
|
|
||||||
: undefined,
|
|
||||||
rawTranscriptJsonl,
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = redactSensitiveInfo(jsonStringify(data))
|
|
||||||
|
|
||||||
await checkAndRefreshOAuthTokenIfNeeded()
|
|
||||||
|
|
||||||
const authResult = getAuthHeaders()
|
|
||||||
if (authResult.error) {
|
|
||||||
return { success: false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'User-Agent': getUserAgent(),
|
|
||||||
...authResult.headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.post(
|
|
||||||
'https://api.anthropic.com/api/claude_code_shared_session_transcripts',
|
|
||||||
{ content, appearance_id: appearanceId },
|
|
||||||
{
|
|
||||||
headers,
|
|
||||||
timeout: 30000,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if (response.status === 200 || response.status === 201) {
|
|
||||||
const result = response.data
|
|
||||||
logForDebugging('Transcript shared successfully', { level: 'info' })
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
transcriptId: result?.transcript_id,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: false }
|
|
||||||
} catch (err) {
|
|
||||||
logForDebugging(errorMessage(err), {
|
|
||||||
level: 'error',
|
|
||||||
})
|
|
||||||
return { success: false }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -448,9 +448,7 @@ export async function getSystemPrompt(
|
|||||||
mcpClients?: MCPServerConnection[],
|
mcpClients?: MCPServerConnection[],
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
|
||||||
return [
|
return [`You are Claude Code, Anthropic's official CLI for Claude.`]
|
||||||
`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const cwd = getCwd()
|
const cwd = getCwd()
|
||||||
@@ -607,8 +605,6 @@ export async function computeEnvInfo(
|
|||||||
modelId: string,
|
modelId: string,
|
||||||
additionalWorkingDirectories?: string[],
|
additionalWorkingDirectories?: string[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
|
|
||||||
|
|
||||||
// Undercover: keep ALL model names/IDs out of the system prompt so nothing
|
// Undercover: keep ALL model names/IDs out of the system prompt so nothing
|
||||||
// internal can leak into public commits/PRs. This includes the public
|
// internal can leak into public commits/PRs. This includes the public
|
||||||
// FRONTIER_MODEL_* constants — if those ever point at an unannounced model,
|
// FRONTIER_MODEL_* constants — if those ever point at an unannounced model,
|
||||||
@@ -627,33 +623,20 @@ export async function computeEnvInfo(
|
|||||||
: `You are powered by the model ${modelId}.`
|
: `You are powered by the model ${modelId}.`
|
||||||
}
|
}
|
||||||
|
|
||||||
const additionalDirsInfo =
|
|
||||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
|
||||||
? `Additional working directories: ${additionalWorkingDirectories.join(', ')}\n`
|
|
||||||
: ''
|
|
||||||
|
|
||||||
const cutoff = getKnowledgeCutoff(modelId)
|
const cutoff = getKnowledgeCutoff(modelId)
|
||||||
const knowledgeCutoffMessage = cutoff
|
const knowledgeCutoffMessage = cutoff
|
||||||
? `\n\nAssistant knowledge cutoff is ${cutoff}.`
|
? `\n\nAssistant knowledge cutoff is ${cutoff}.`
|
||||||
: ''
|
: ''
|
||||||
|
|
||||||
return `Here is useful information about the environment you are running in:
|
return [`# Environment`, `You are Claude Code.`, modelDescription, knowledgeCutoffMessage]
|
||||||
<env>
|
.filter(Boolean)
|
||||||
Working directory: ${getCwd()}
|
.join('\n')
|
||||||
Is directory a git repo: ${isGit ? 'Yes' : 'No'}
|
|
||||||
${additionalDirsInfo}Platform: ${env.platform}
|
|
||||||
${getShellInfoLine()}
|
|
||||||
OS Version: ${unameSR}
|
|
||||||
</env>
|
|
||||||
${modelDescription}${knowledgeCutoffMessage}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function computeSimpleEnvInfo(
|
export async function computeSimpleEnvInfo(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
additionalWorkingDirectories?: string[],
|
additionalWorkingDirectories?: string[],
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [isGit, unameSR] = await Promise.all([getIsGit(), getUnameSR()])
|
|
||||||
|
|
||||||
// Undercover: strip all model name/ID references. See computeEnvInfo.
|
// Undercover: strip all model name/ID references. See computeEnvInfo.
|
||||||
// DCE: inline the USER_TYPE check at each site — do NOT hoist to a const.
|
// DCE: inline the USER_TYPE check at each site — do NOT hoist to a const.
|
||||||
let modelDescription: string | null = null
|
let modelDescription: string | null = null
|
||||||
@@ -671,42 +654,14 @@ export async function computeSimpleEnvInfo(
|
|||||||
? `Assistant knowledge cutoff is ${cutoff}.`
|
? `Assistant knowledge cutoff is ${cutoff}.`
|
||||||
: null
|
: null
|
||||||
|
|
||||||
const cwd = getCwd()
|
|
||||||
const isWorktree = getCurrentWorktreeSession() !== null
|
|
||||||
|
|
||||||
const envItems = [
|
|
||||||
`Primary working directory: ${cwd}`,
|
|
||||||
isWorktree
|
|
||||||
? `This is a git worktree — an isolated copy of the repository. Run all commands from this directory. Do NOT \`cd\` to the original repository root.`
|
|
||||||
: null,
|
|
||||||
[`Is a git repository: ${isGit}`],
|
|
||||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
|
||||||
? `Additional working directories:`
|
|
||||||
: null,
|
|
||||||
additionalWorkingDirectories && additionalWorkingDirectories.length > 0
|
|
||||||
? additionalWorkingDirectories
|
|
||||||
: null,
|
|
||||||
`Platform: ${env.platform}`,
|
|
||||||
getShellInfoLine(),
|
|
||||||
`OS Version: ${unameSR}`,
|
|
||||||
modelDescription,
|
|
||||||
knowledgeCutoffMessage,
|
|
||||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
|
||||||
? null
|
|
||||||
: `The most recent Claude model family is Claude 4.5/4.6. Model IDs — Opus 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.opus}', Sonnet 4.6: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.sonnet}', Haiku 4.5: '${CLAUDE_4_5_OR_4_6_MODEL_IDS.haiku}'. When building AI applications, default to the latest and most capable Claude models.`,
|
|
||||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
|
||||||
? null
|
|
||||||
: `Claude Code is available as a CLI in the terminal, desktop app (Mac/Windows), web app (claude.ai/code), and IDE extensions (VS Code, JetBrains).`,
|
|
||||||
process.env.USER_TYPE === 'ant' && isUndercover()
|
|
||||||
? null
|
|
||||||
: `Fast mode for Claude Code uses the same ${FRONTIER_MODEL_NAME} model with faster output. It does NOT switch to a different model. It can be toggled with /fast.`,
|
|
||||||
].filter(item => item !== null)
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
`# Environment`,
|
`# Environment`,
|
||||||
`You have been invoked in the following environment: `,
|
`You are Claude Code.`,
|
||||||
...prependBullets(envItems),
|
modelDescription,
|
||||||
].join(`\n`)
|
knowledgeCutoffMessage,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(`\n`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model.
|
// @[MODEL LAUNCH]: Add a knowledge cutoff date for the new model.
|
||||||
|
|||||||
179
src/context.ts
179
src/context.ts
@@ -1,25 +1,7 @@
|
|||||||
import { feature } from 'bun:bundle'
|
|
||||||
import memoize from 'lodash-es/memoize.js'
|
import memoize from 'lodash-es/memoize.js'
|
||||||
import {
|
import { setCachedClaudeMdContent } from './bootstrap/state.js'
|
||||||
getAdditionalDirectoriesForClaudeMd,
|
|
||||||
setCachedClaudeMdContent,
|
|
||||||
} from './bootstrap/state.js'
|
|
||||||
import { getLocalISODate } from './constants/common.js'
|
|
||||||
import {
|
|
||||||
filterInjectedMemoryFiles,
|
|
||||||
getClaudeMds,
|
|
||||||
getMemoryFiles,
|
|
||||||
} from './utils/claudemd.js'
|
|
||||||
import { logForDiagnosticsNoPII } from './utils/diagLogs.js'
|
|
||||||
import { isBareMode, isEnvTruthy } from './utils/envUtils.js'
|
|
||||||
import { execFileNoThrow } from './utils/execFileNoThrow.js'
|
|
||||||
import { getBranch, getDefaultBranch, getIsGit, gitExe } from './utils/git.js'
|
|
||||||
import { shouldIncludeGitInstructions } from './utils/gitSettings.js'
|
|
||||||
import { logError } from './utils/log.js'
|
|
||||||
|
|
||||||
const MAX_STATUS_CHARS = 2000
|
// System prompt injection remains a local cache-busting hook only.
|
||||||
|
|
||||||
// System prompt injection for cache breaking (ant-only, ephemeral debugging state)
|
|
||||||
let systemPromptInjection: string | null = null
|
let systemPromptInjection: string | null = null
|
||||||
|
|
||||||
export function getSystemPromptInjection(): string | null {
|
export function getSystemPromptInjection(): string | null {
|
||||||
@@ -28,162 +10,17 @@ export function getSystemPromptInjection(): string | null {
|
|||||||
|
|
||||||
export function setSystemPromptInjection(value: string | null): void {
|
export function setSystemPromptInjection(value: string | null): void {
|
||||||
systemPromptInjection = value
|
systemPromptInjection = value
|
||||||
// Clear context caches immediately when injection changes
|
|
||||||
getUserContext.cache.clear?.()
|
getUserContext.cache.clear?.()
|
||||||
getSystemContext.cache.clear?.()
|
getSystemContext.cache.clear?.()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getGitStatus = memoize(async (): Promise<string | null> => {
|
export const getGitStatus = memoize(async (): Promise<string | null> => null)
|
||||||
if (process.env.NODE_ENV === 'test') {
|
|
||||||
// Avoid cycles in tests
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = Date.now()
|
|
||||||
logForDiagnosticsNoPII('info', 'git_status_started')
|
|
||||||
|
|
||||||
const isGitStart = Date.now()
|
|
||||||
const isGit = await getIsGit()
|
|
||||||
logForDiagnosticsNoPII('info', 'git_is_git_check_completed', {
|
|
||||||
duration_ms: Date.now() - isGitStart,
|
|
||||||
is_git: isGit,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!isGit) {
|
|
||||||
logForDiagnosticsNoPII('info', 'git_status_skipped_not_git', {
|
|
||||||
duration_ms: Date.now() - startTime,
|
|
||||||
})
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const gitCmdsStart = Date.now()
|
|
||||||
const [branch, mainBranch, status, log, userName] = await Promise.all([
|
|
||||||
getBranch(),
|
|
||||||
getDefaultBranch(),
|
|
||||||
execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], {
|
|
||||||
preserveOutputOnError: false,
|
|
||||||
}).then(({ stdout }) => stdout.trim()),
|
|
||||||
execFileNoThrow(
|
|
||||||
gitExe(),
|
|
||||||
['--no-optional-locks', 'log', '--oneline', '-n', '5'],
|
|
||||||
{
|
|
||||||
preserveOutputOnError: false,
|
|
||||||
},
|
|
||||||
).then(({ stdout }) => stdout.trim()),
|
|
||||||
execFileNoThrow(gitExe(), ['config', 'user.name'], {
|
|
||||||
preserveOutputOnError: false,
|
|
||||||
}).then(({ stdout }) => stdout.trim()),
|
|
||||||
])
|
|
||||||
|
|
||||||
logForDiagnosticsNoPII('info', 'git_commands_completed', {
|
|
||||||
duration_ms: Date.now() - gitCmdsStart,
|
|
||||||
status_length: status.length,
|
|
||||||
})
|
|
||||||
|
|
||||||
// Check if status exceeds character limit
|
|
||||||
const truncatedStatus =
|
|
||||||
status.length > MAX_STATUS_CHARS
|
|
||||||
? status.substring(0, MAX_STATUS_CHARS) +
|
|
||||||
'\n... (truncated because it exceeds 2k characters. If you need more information, run "git status" using BashTool)'
|
|
||||||
: status
|
|
||||||
|
|
||||||
logForDiagnosticsNoPII('info', 'git_status_completed', {
|
|
||||||
duration_ms: Date.now() - startTime,
|
|
||||||
truncated: status.length > MAX_STATUS_CHARS,
|
|
||||||
})
|
|
||||||
|
|
||||||
return [
|
|
||||||
`This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.`,
|
|
||||||
`Current branch: ${branch}`,
|
|
||||||
`Main branch (you will usually use this for PRs): ${mainBranch}`,
|
|
||||||
...(userName ? [`Git user: ${userName}`] : []),
|
|
||||||
`Status:\n${truncatedStatus || '(clean)'}`,
|
|
||||||
`Recent commits:\n${log}`,
|
|
||||||
].join('\n\n')
|
|
||||||
} catch (error) {
|
|
||||||
logForDiagnosticsNoPII('error', 'git_status_failed', {
|
|
||||||
duration_ms: Date.now() - startTime,
|
|
||||||
})
|
|
||||||
logError(error)
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This context is prepended to each conversation, and cached for the duration of the conversation.
|
|
||||||
*/
|
|
||||||
export const getSystemContext = memoize(
|
export const getSystemContext = memoize(
|
||||||
async (): Promise<{
|
async (): Promise<Record<string, string>> => ({}),
|
||||||
[k: string]: string
|
|
||||||
}> => {
|
|
||||||
const startTime = Date.now()
|
|
||||||
logForDiagnosticsNoPII('info', 'system_context_started')
|
|
||||||
|
|
||||||
// Skip git status in CCR (unnecessary overhead on resume) or when git instructions are disabled
|
|
||||||
const gitStatus =
|
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
|
|
||||||
!shouldIncludeGitInstructions()
|
|
||||||
? null
|
|
||||||
: await getGitStatus()
|
|
||||||
|
|
||||||
// Include system prompt injection if set (for cache breaking, ant-only)
|
|
||||||
const injection = feature('BREAK_CACHE_COMMAND')
|
|
||||||
? getSystemPromptInjection()
|
|
||||||
: null
|
|
||||||
|
|
||||||
logForDiagnosticsNoPII('info', 'system_context_completed', {
|
|
||||||
duration_ms: Date.now() - startTime,
|
|
||||||
has_git_status: gitStatus !== null,
|
|
||||||
has_injection: injection !== null,
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(gitStatus && { gitStatus }),
|
|
||||||
...(feature('BREAK_CACHE_COMMAND') && injection
|
|
||||||
? {
|
|
||||||
cacheBreaker: `[CACHE_BREAKER: ${injection}]`,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
export const getUserContext = memoize(async (): Promise<Record<string, string>> => {
|
||||||
* This context is prepended to each conversation, and cached for the duration of the conversation.
|
setCachedClaudeMdContent(null)
|
||||||
*/
|
return {}
|
||||||
export const getUserContext = memoize(
|
})
|
||||||
async (): Promise<{
|
|
||||||
[k: string]: string
|
|
||||||
}> => {
|
|
||||||
const startTime = Date.now()
|
|
||||||
logForDiagnosticsNoPII('info', 'user_context_started')
|
|
||||||
|
|
||||||
// CLAUDE_CODE_DISABLE_CLAUDE_MDS: hard off, always.
|
|
||||||
// --bare: skip auto-discovery (cwd walk), BUT honor explicit --add-dir.
|
|
||||||
// --bare means "skip what I didn't ask for", not "ignore what I asked for".
|
|
||||||
const shouldDisableClaudeMd =
|
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
|
|
||||||
(isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
|
|
||||||
// Await the async I/O (readFile/readdir directory walk) so the event
|
|
||||||
// loop yields naturally at the first fs.readFile.
|
|
||||||
const claudeMd = shouldDisableClaudeMd
|
|
||||||
? null
|
|
||||||
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
|
|
||||||
// Cache for the auto-mode classifier (yoloClassifier.ts reads this
|
|
||||||
// instead of importing claudemd.ts directly, which would create a
|
|
||||||
// cycle through permissions/filesystem → permissions → yoloClassifier).
|
|
||||||
setCachedClaudeMdContent(claudeMd || null)
|
|
||||||
|
|
||||||
logForDiagnosticsNoPII('info', 'user_context_completed', {
|
|
||||||
duration_ms: Date.now() - startTime,
|
|
||||||
claudemd_length: claudeMd?.length ?? 0,
|
|
||||||
claudemd_disabled: Boolean(shouldDisableClaudeMd),
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
...(claudeMd && { claudeMd }),
|
|
||||||
currentDate: `Today's date is ${getLocalISODate()}.`,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -55,7 +55,6 @@ import {
|
|||||||
stripSignatureBlocks,
|
stripSignatureBlocks,
|
||||||
} from './utils/messages.js'
|
} from './utils/messages.js'
|
||||||
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
import { generateToolUseSummary } from './services/toolUseSummary/toolUseSummaryGenerator.js'
|
||||||
import { prependUserContext, appendSystemContext } from './utils/api.js'
|
|
||||||
import {
|
import {
|
||||||
createAttachmentMessage,
|
createAttachmentMessage,
|
||||||
filterDuplicateMemoryAttachments,
|
filterDuplicateMemoryAttachments,
|
||||||
@@ -446,9 +445,7 @@ async function* queryLoop(
|
|||||||
messagesForQuery = collapseResult.messages
|
messagesForQuery = collapseResult.messages
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullSystemPrompt = asSystemPrompt(
|
const fullSystemPrompt = asSystemPrompt(systemPrompt)
|
||||||
appendSystemContext(systemPrompt, systemContext),
|
|
||||||
)
|
|
||||||
|
|
||||||
queryCheckpoint('query_autocompact_start')
|
queryCheckpoint('query_autocompact_start')
|
||||||
const { compactionResult, consecutiveFailures } = await deps.autocompact(
|
const { compactionResult, consecutiveFailures } = await deps.autocompact(
|
||||||
@@ -657,7 +654,7 @@ async function* queryLoop(
|
|||||||
let streamingFallbackOccured = false
|
let streamingFallbackOccured = false
|
||||||
queryCheckpoint('query_api_streaming_start')
|
queryCheckpoint('query_api_streaming_start')
|
||||||
for await (const message of deps.callModel({
|
for await (const message of deps.callModel({
|
||||||
messages: prependUserContext(messagesForQuery, userContext),
|
messages: messagesForQuery,
|
||||||
systemPrompt: fullSystemPrompt,
|
systemPrompt: fullSystemPrompt,
|
||||||
thinkingConfig: toolUseContext.options.thinkingConfig,
|
thinkingConfig: toolUseContext.options.thinkingConfig,
|
||||||
tools: toolUseContext.options.tools,
|
tools: toolUseContext.options.tools,
|
||||||
|
|||||||
110
src/utils/api.ts
110
src/utils/api.ts
@@ -438,39 +438,14 @@ export function appendSystemContext(
|
|||||||
systemPrompt: SystemPrompt,
|
systemPrompt: SystemPrompt,
|
||||||
context: { [k: string]: string },
|
context: { [k: string]: string },
|
||||||
): string[] {
|
): string[] {
|
||||||
return [
|
return systemPrompt
|
||||||
...systemPrompt,
|
|
||||||
Object.entries(context)
|
|
||||||
.map(([key, value]) => `${key}: ${value}`)
|
|
||||||
.join('\n'),
|
|
||||||
].filter(Boolean)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prependUserContext(
|
export function prependUserContext(
|
||||||
messages: Message[],
|
messages: Message[],
|
||||||
context: { [k: string]: string },
|
context: { [k: string]: string },
|
||||||
): Message[] {
|
): Message[] {
|
||||||
if (process.env.NODE_ENV === 'test') {
|
return messages
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.entries(context).length === 0) {
|
|
||||||
return messages
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
createUserMessage({
|
|
||||||
content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${Object.entries(
|
|
||||||
context,
|
|
||||||
)
|
|
||||||
.map(([key, value]) => `# ${key}\n${value}`)
|
|
||||||
.join('\n')}
|
|
||||||
|
|
||||||
IMPORTANT: this context may or may not be relevant to your tasks. You should not respond to this context unless it is highly relevant to your task.\n</system-reminder>\n`,
|
|
||||||
isMeta: true,
|
|
||||||
}),
|
|
||||||
...messages,
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -480,86 +455,7 @@ export async function logContextMetrics(
|
|||||||
mcpConfigs: Record<string, ScopedMcpServerConfig>,
|
mcpConfigs: Record<string, ScopedMcpServerConfig>,
|
||||||
toolPermissionContext: ToolPermissionContext,
|
toolPermissionContext: ToolPermissionContext,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Early return if logging is disabled
|
return
|
||||||
if (isAnalyticsDisabled()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const [{ tools: mcpTools }, tools, userContext, systemContext] =
|
|
||||||
await Promise.all([
|
|
||||||
prefetchAllMcpResources(mcpConfigs),
|
|
||||||
getTools(toolPermissionContext),
|
|
||||||
getUserContext(),
|
|
||||||
getSystemContext(),
|
|
||||||
])
|
|
||||||
// Extract individual context sizes and calculate total
|
|
||||||
const gitStatusSize = systemContext.gitStatus?.length ?? 0
|
|
||||||
const claudeMdSize = userContext.claudeMd?.length ?? 0
|
|
||||||
|
|
||||||
// Calculate total context size
|
|
||||||
const totalContextSize = gitStatusSize + claudeMdSize
|
|
||||||
|
|
||||||
// Get file count using ripgrep (rounded to nearest power of 10 for privacy)
|
|
||||||
const currentDir = getCwd()
|
|
||||||
const ignorePatternsByRoot = getFileReadIgnorePatterns(toolPermissionContext)
|
|
||||||
const normalizedIgnorePatterns = normalizePatternsToPath(
|
|
||||||
ignorePatternsByRoot,
|
|
||||||
currentDir,
|
|
||||||
)
|
|
||||||
const fileCount = await countFilesRoundedRg(
|
|
||||||
currentDir,
|
|
||||||
AbortSignal.timeout(1000),
|
|
||||||
normalizedIgnorePatterns,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Calculate tool metrics
|
|
||||||
let mcpToolsCount = 0
|
|
||||||
let mcpServersCount = 0
|
|
||||||
let mcpToolsTokens = 0
|
|
||||||
let nonMcpToolsCount = 0
|
|
||||||
let nonMcpToolsTokens = 0
|
|
||||||
|
|
||||||
const nonMcpTools = tools.filter(tool => !tool.isMcp)
|
|
||||||
mcpToolsCount = mcpTools.length
|
|
||||||
nonMcpToolsCount = nonMcpTools.length
|
|
||||||
|
|
||||||
// Extract unique server names from MCP tool names (format: mcp__servername__toolname)
|
|
||||||
const serverNames = new Set<string>()
|
|
||||||
for (const tool of mcpTools) {
|
|
||||||
const parts = tool.name.split('__')
|
|
||||||
if (parts.length >= 3 && parts[1]) {
|
|
||||||
serverNames.add(parts[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mcpServersCount = serverNames.size
|
|
||||||
|
|
||||||
// Estimate tool tokens locally for analytics (avoids N API calls per session)
|
|
||||||
// Use inputJSONSchema (plain JSON Schema) when available, otherwise convert Zod schema
|
|
||||||
for (const tool of mcpTools) {
|
|
||||||
const schema =
|
|
||||||
'inputJSONSchema' in tool && tool.inputJSONSchema
|
|
||||||
? tool.inputJSONSchema
|
|
||||||
: zodToJsonSchema(tool.inputSchema)
|
|
||||||
mcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema))
|
|
||||||
}
|
|
||||||
for (const tool of nonMcpTools) {
|
|
||||||
const schema =
|
|
||||||
'inputJSONSchema' in tool && tool.inputJSONSchema
|
|
||||||
? tool.inputJSONSchema
|
|
||||||
: zodToJsonSchema(tool.inputSchema)
|
|
||||||
nonMcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema))
|
|
||||||
}
|
|
||||||
|
|
||||||
logEvent('tengu_context_size', {
|
|
||||||
git_status_size: gitStatusSize,
|
|
||||||
claude_md_size: claudeMdSize,
|
|
||||||
total_context_size: totalContextSize,
|
|
||||||
project_file_count_rounded: fileCount,
|
|
||||||
mcp_tools_count: mcpToolsCount,
|
|
||||||
mcp_servers_count: mcpServersCount,
|
|
||||||
mcp_tools_tokens: mcpToolsTokens,
|
|
||||||
non_mcp_tools_count: nonMcpToolsCount,
|
|
||||||
non_mcp_tools_tokens: nonMcpToolsTokens,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Generalize this to all tools
|
// TODO: Generalize this to all tools
|
||||||
|
|||||||
Reference in New Issue
Block a user