feat: open useful USER_TYPE-gated features to all users (#644)
* feat: open useful USER_TYPE-gated features to all users Remove 13 process.env.USER_TYPE === 'ant' gates that restricted useful features to Anthropic employees. These features work without Anthropic infrastructure and are now available to all open-build users. Features opened: - Agent nesting (sub-agents can spawn sub-agents) - Effort 'max' persistence in settings - Plan mode interview phase (controlled by feature flags) - Sandbox disabled commands (via ~/.claude/feature-flags.json) - All tips visible to all users (plan mode, feedback, shift-tab) Simplified: - Fullscreen defaults to off (use /config to enable) - Explore agent always uses haiku model - Plan mode tool uses conservative prompt for all users Continues the USER_TYPE cleanup from #637 (dead code) and builds on #639 (local feature flags). * fix: address Copilot review comments — remove residual dead code 1. bridgeConfig.ts: ungate bridge override functions — return env vars directly instead of hardcoded undefined 2. bridgeMain.ts + initReplBridge.ts: ungate sessionIngressUrl — read CLAUDE_BRIDGE_SESSION_INGRESS_URL without USER_TYPE check 3. tools.ts: remove dead ConfigTool/TungstenTool imports, narrow eslint-disable scope, stub REPLTool/SuggestBackgroundPRTool to null 4. readOnlyValidation.ts: remove orphaned ANT_ONLY_COMMAND_ALLOWLIST and unused GH_READ_ONLY_COMMANDS import 5. insights.ts: remove entire remote collection plumbing (types, functions, options, display logic) 6. osc.ts: hardcode supportsTabStatus() to false (internal-only feature) 7. state.ts: simplify addSlowOperation/getSlowOperations to no-ops, remove dead constants * fix: address Copilot review on PR #644 1. settings/types.ts: allow 'max' effort level for all users in Zod schema — was still gated behind USER_TYPE=ant, causing 'max' to be silently dropped on settings reload 2. shouldUseSandbox.ts: defensively normalize disabledCommands from feature flag config with Array.isArray() guards * fix: address second round of Copilot review on PR #644 1. shouldUseSandbox.ts: validate top-level shape of disabledCommands before accessing properties (handles null/primitive from feature flag) 2. fullscreen.ts: update JSDoc to reflect removal of USER_TYPE default 3. osc.ts: update JSDoc — "Ant-only" → "Currently disabled"
This commit is contained in:
committed by
GitHub
parent
658d076909
commit
c1beea9867
@@ -1562,29 +1562,8 @@ export function clearInvokedSkillsForAgent(agentId: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Slow operations tracking for dev bar
|
||||
const MAX_SLOW_OPERATIONS = 10
|
||||
const SLOW_OPERATION_TTL_MS = 10000
|
||||
|
||||
export function addSlowOperation(operation: string, durationMs: number): void {
|
||||
if (process.env.USER_TYPE !== 'ant') return
|
||||
// Skip tracking for editor sessions (user editing a prompt file in $EDITOR)
|
||||
// These are intentionally slow since the user is drafting text
|
||||
if (operation.includes('exec') && operation.includes('claude-prompt-')) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
// Remove stale operations
|
||||
STATE.slowOperations = STATE.slowOperations.filter(
|
||||
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
|
||||
)
|
||||
// Add new operation
|
||||
STATE.slowOperations.push({ operation, durationMs, timestamp: now })
|
||||
// Keep only the most recent operations
|
||||
if (STATE.slowOperations.length > MAX_SLOW_OPERATIONS) {
|
||||
STATE.slowOperations = STATE.slowOperations.slice(-MAX_SLOW_OPERATIONS)
|
||||
}
|
||||
}
|
||||
// Slow operations tracking removed (was internal-only).
|
||||
// Functions kept as no-ops to avoid breaking callers.
|
||||
|
||||
const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
|
||||
operation: string
|
||||
@@ -1592,32 +1571,17 @@ const EMPTY_SLOW_OPERATIONS: ReadonlyArray<{
|
||||
timestamp: number
|
||||
}> = []
|
||||
|
||||
export function addSlowOperation(
|
||||
_operation: string,
|
||||
_durationMs: number,
|
||||
): void {}
|
||||
|
||||
export function getSlowOperations(): ReadonlyArray<{
|
||||
operation: string
|
||||
durationMs: number
|
||||
timestamp: number
|
||||
}> {
|
||||
// Most common case: nothing tracked. Return a stable reference so the
|
||||
// caller's setState() can bail via Object.is instead of re-rendering at 2fps.
|
||||
if (STATE.slowOperations.length === 0) {
|
||||
return EMPTY_SLOW_OPERATIONS
|
||||
}
|
||||
const now = Date.now()
|
||||
// Only allocate a new array when something actually expired; otherwise keep
|
||||
// the reference stable across polls while ops are still fresh.
|
||||
if (
|
||||
STATE.slowOperations.some(op => now - op.timestamp >= SLOW_OPERATION_TTL_MS)
|
||||
) {
|
||||
STATE.slowOperations = STATE.slowOperations.filter(
|
||||
op => now - op.timestamp < SLOW_OPERATION_TTL_MS,
|
||||
)
|
||||
if (STATE.slowOperations.length === 0) {
|
||||
return EMPTY_SLOW_OPERATIONS
|
||||
}
|
||||
}
|
||||
// Safe to return directly: addSlowOperation() reassigns STATE.slowOperations
|
||||
// before pushing, so the array held in React state is never mutated.
|
||||
return STATE.slowOperations
|
||||
return EMPTY_SLOW_OPERATIONS
|
||||
}
|
||||
|
||||
export function getMainThreadAgentType(): string | undefined {
|
||||
|
||||
@@ -14,21 +14,14 @@
|
||||
import { getOauthConfig } from '../constants/oauth.js'
|
||||
import { getClaudeAIOAuthTokens } from '../utils/auth.js'
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_OAUTH_TOKEN, else undefined. */
|
||||
/** 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
|
||||
)
|
||||
return process.env.CLAUDE_BRIDGE_OAUTH_TOKEN || undefined
|
||||
}
|
||||
|
||||
/** Ant-only dev override: CLAUDE_BRIDGE_BASE_URL, else undefined. */
|
||||
/** 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
|
||||
)
|
||||
return process.env.CLAUDE_BRIDGE_BASE_URL || undefined
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2194,14 +2194,10 @@ export async function bridgeMain(args: string[]): Promise<void> {
|
||||
|
||||
// Session ingress URL for WebSocket connections. In production this is the
|
||||
// same as baseUrl (Envoy routes /v1/session_ingress/* to session-ingress).
|
||||
// Locally, session-ingress runs on a different port (9413) than the
|
||||
// contain-provide-api (8211), so CLAUDE_BRIDGE_SESSION_INGRESS_URL must be
|
||||
// set explicitly. Ant-only, matching CLAUDE_BRIDGE_BASE_URL.
|
||||
// Locally, session-ingress may run on a different port, so
|
||||
// CLAUDE_BRIDGE_SESSION_INGRESS_URL can override the default.
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
@@ -2851,10 +2847,7 @@ export async function runBridgeHeadless(
|
||||
)
|
||||
}
|
||||
const sessionIngressUrl =
|
||||
process.env.USER_TYPE === 'ant' &&
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
? process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL
|
||||
: baseUrl
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
const { getBranch, getRemoteUrl, findGitRoot } = await import(
|
||||
'../utils/git.js'
|
||||
|
||||
@@ -465,10 +465,7 @@ export async function initReplBridge(
|
||||
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
|
||||
process.env.CLAUDE_BRIDGE_SESSION_INGRESS_URL || baseUrl
|
||||
|
||||
// Assistant-mode sessions advertise a distinct worker_type so the web UI
|
||||
// can filter them into a dedicated picker. KAIROS guard keeps the
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { execFileSync } from 'child_process'
|
||||
import { diffLines } from 'diff'
|
||||
import { constants as fsConstants } from 'fs'
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
unlink,
|
||||
writeFile,
|
||||
} from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { extname, join } from 'path'
|
||||
import type { Command } from '../commands.js'
|
||||
import { queryWithModel } from '../services/api/claude.js'
|
||||
@@ -22,7 +17,6 @@ import {
|
||||
import type { LogOption } from '../types/logs.js'
|
||||
import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
|
||||
import { toError } from '../utils/errors.js'
|
||||
import { execFileNoThrow } from '../utils/execFileNoThrow.js'
|
||||
import { logError } from '../utils/log.js'
|
||||
import { extractTextContent } from '../utils/messages.js'
|
||||
import { getDefaultOpusModel } from '../utils/model/model.js'
|
||||
@@ -47,180 +41,6 @@ function getInsightsModel(): string {
|
||||
return getDefaultOpusModel()
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Homespace Data Collection
|
||||
// ============================================================================
|
||||
|
||||
type RemoteHostInfo = {
|
||||
name: string
|
||||
sessionCount: number
|
||||
}
|
||||
|
||||
/* eslint-disable custom-rules/no-process-env-top-level */
|
||||
const getRunningRemoteHosts: () => Promise<string[]> =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? async () => {
|
||||
const { stdout, code } = await execFileNoThrow(
|
||||
'coder',
|
||||
['list', '-o', 'json'],
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
if (code !== 0) return []
|
||||
try {
|
||||
const workspaces = jsonParse(stdout) as Array<{
|
||||
name: string
|
||||
latest_build?: { status?: string }
|
||||
}>
|
||||
return workspaces
|
||||
.filter(w => w.latest_build?.status === 'running')
|
||||
.map(w => w.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
: async () => []
|
||||
|
||||
const getRemoteHostSessionCount: (hs: string) => Promise<number> =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? async (homespace: string) => {
|
||||
const { stdout, code } = await execFileNoThrow(
|
||||
'ssh',
|
||||
[
|
||||
`${homespace}.coder`,
|
||||
'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l',
|
||||
],
|
||||
{ timeout: 30000 },
|
||||
)
|
||||
if (code !== 0) return 0
|
||||
return parseInt(stdout.trim(), 10) || 0
|
||||
}
|
||||
: async () => 0
|
||||
|
||||
const collectFromRemoteHost: (
|
||||
hs: string,
|
||||
destDir: string,
|
||||
) => Promise<{ copied: number; skipped: number }> =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? async (homespace: string, destDir: string) => {
|
||||
const result = { copied: 0, skipped: 0 }
|
||||
|
||||
// Create temp directory
|
||||
const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-'))
|
||||
|
||||
try {
|
||||
// SCP the projects folder
|
||||
const scpResult = await execFileNoThrow(
|
||||
'scp',
|
||||
['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir],
|
||||
{ timeout: 300000 },
|
||||
)
|
||||
if (scpResult.code !== 0) {
|
||||
// SCP failed
|
||||
return result
|
||||
}
|
||||
|
||||
const projectsDir = join(tempDir, 'projects')
|
||||
let projectDirents: Awaited<ReturnType<typeof readdir>>
|
||||
try {
|
||||
projectDirents = await readdir(projectsDir, { withFileTypes: true })
|
||||
} catch {
|
||||
return result
|
||||
}
|
||||
|
||||
// Merge into destination (parallel per project directory)
|
||||
await Promise.all(
|
||||
projectDirents.map(async dirent => {
|
||||
const projectName = dirent.name
|
||||
const projectPath = join(projectsDir, projectName)
|
||||
|
||||
// Skip if not a directory
|
||||
if (!dirent.isDirectory()) return
|
||||
|
||||
const destProjectName = `${projectName}__${homespace}`
|
||||
const destProjectPath = join(destDir, destProjectName)
|
||||
|
||||
try {
|
||||
await mkdir(destProjectPath, { recursive: true })
|
||||
} catch {
|
||||
// Directory may already exist
|
||||
}
|
||||
|
||||
// Copy session files (skip existing)
|
||||
let files: Awaited<ReturnType<typeof readdir>>
|
||||
try {
|
||||
files = await readdir(projectPath, { withFileTypes: true })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Promise.all(
|
||||
files.map(async fileDirent => {
|
||||
const fileName = fileDirent.name
|
||||
if (!fileName.endsWith('.jsonl')) return
|
||||
|
||||
const srcFile = join(projectPath, fileName)
|
||||
const destFile = join(destProjectPath, fileName)
|
||||
|
||||
try {
|
||||
await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL)
|
||||
result.copied++
|
||||
} catch {
|
||||
// EEXIST from COPYFILE_EXCL means dest already exists
|
||||
result.skipped++
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
} finally {
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
: async () => ({ copied: 0, skipped: 0 })
|
||||
|
||||
const collectAllRemoteHostData: (destDir: string) => Promise<{
|
||||
hosts: RemoteHostInfo[]
|
||||
totalCopied: number
|
||||
totalSkipped: number
|
||||
}> =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? async (destDir: string) => {
|
||||
const rHosts = await getRunningRemoteHosts()
|
||||
const result: RemoteHostInfo[] = []
|
||||
let totalCopied = 0
|
||||
let totalSkipped = 0
|
||||
|
||||
// Collect from all hosts in parallel (SCP per host can take seconds)
|
||||
const hostResults = await Promise.all(
|
||||
rHosts.map(async hs => {
|
||||
const sessionCount = await getRemoteHostSessionCount(hs)
|
||||
if (sessionCount > 0) {
|
||||
const { copied, skipped } = await collectFromRemoteHost(
|
||||
hs,
|
||||
destDir,
|
||||
)
|
||||
return { name: hs, sessionCount, copied, skipped }
|
||||
}
|
||||
return { name: hs, sessionCount, copied: 0, skipped: 0 }
|
||||
}),
|
||||
)
|
||||
|
||||
for (const hr of hostResults) {
|
||||
result.push({ name: hr.name, sessionCount: hr.sessionCount })
|
||||
totalCopied += hr.copied
|
||||
totalSkipped += hr.skipped
|
||||
}
|
||||
|
||||
return { hosts: result, totalCopied, totalSkipped }
|
||||
}
|
||||
: async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 })
|
||||
/* eslint-enable custom-rules/no-process-env-top-level */
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
@@ -2659,7 +2479,6 @@ export type InsightsExport = {
|
||||
claude_code_version: string
|
||||
date_range: { start: string; end: string }
|
||||
session_count: number
|
||||
remote_hosts_collected?: string[]
|
||||
}
|
||||
aggregated_data: AggregatedData
|
||||
insights: InsightResults
|
||||
@@ -2680,14 +2499,9 @@ export function buildExportData(
|
||||
data: AggregatedData,
|
||||
insights: InsightResults,
|
||||
facets: Map<string, SessionFacets>,
|
||||
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
|
||||
): InsightsExport {
|
||||
const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown'
|
||||
|
||||
const remote_hosts_collected = remoteStats?.hosts
|
||||
.filter(h => h.sessionCount > 0)
|
||||
.map(h => h.name)
|
||||
|
||||
const facets_summary = {
|
||||
total: facets.size,
|
||||
goal_categories: {} as Record<string, number>,
|
||||
@@ -2725,10 +2539,6 @@ export function buildExportData(
|
||||
claude_code_version: version,
|
||||
date_range: data.date_range,
|
||||
session_count: data.total_sessions,
|
||||
...(remote_hosts_collected &&
|
||||
remote_hosts_collected.length > 0 && {
|
||||
remote_hosts_collected,
|
||||
}),
|
||||
},
|
||||
aggregated_data: data,
|
||||
insights,
|
||||
@@ -2793,24 +2603,12 @@ async function scanAllSessions(): Promise<LiteSessionInfo[]> {
|
||||
// Main Function
|
||||
// ============================================================================
|
||||
|
||||
export async function generateUsageReport(options?: {
|
||||
collectRemote?: boolean
|
||||
}): Promise<{
|
||||
export async function generateUsageReport(): Promise<{
|
||||
insights: InsightResults
|
||||
htmlPath: string
|
||||
data: AggregatedData
|
||||
remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
|
||||
facets: Map<string, SessionFacets>
|
||||
}> {
|
||||
let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
|
||||
|
||||
// Optionally collect data from remote hosts first (internal-only)
|
||||
if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
|
||||
const destDir = join(getClaudeConfigHomeDir(), 'projects')
|
||||
const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
|
||||
remoteStats = { hosts, totalCopied }
|
||||
}
|
||||
|
||||
// Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
|
||||
const allScannedSessions = await scanAllSessions()
|
||||
const totalSessionsScanned = allScannedSessions.length
|
||||
@@ -3017,7 +2815,6 @@ export async function generateUsageReport(options?: {
|
||||
insights,
|
||||
htmlPath,
|
||||
data: aggregated,
|
||||
remoteStats,
|
||||
facets: substantiveFacets,
|
||||
}
|
||||
}
|
||||
@@ -3043,31 +2840,8 @@ const usageReport: Command = {
|
||||
contentLength: 0, // Dynamic content
|
||||
progressMessage: 'analyzing your sessions',
|
||||
source: 'builtin',
|
||||
async getPromptForCommand(args) {
|
||||
let collectRemote = false
|
||||
let remoteHosts: string[] = []
|
||||
let hasRemoteHosts = false
|
||||
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
// Parse --homespaces flag
|
||||
collectRemote = args?.includes('--homespaces') ?? false
|
||||
|
||||
// Check for available remote hosts
|
||||
remoteHosts = await getRunningRemoteHosts()
|
||||
hasRemoteHosts = remoteHosts.length > 0
|
||||
|
||||
// Show collection message if collecting
|
||||
if (collectRemote && hasRemoteHosts) {
|
||||
// biome-ignore lint/suspicious/noConsole: intentional
|
||||
console.error(
|
||||
`Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const { insights, htmlPath, data, remoteStats } = await generateUsageReport(
|
||||
{ collectRemote },
|
||||
)
|
||||
async getPromptForCommand(_args) {
|
||||
const { insights, htmlPath, data } = await generateUsageReport()
|
||||
|
||||
let reportUrl = `file://${htmlPath}`
|
||||
let uploadHint = ''
|
||||
@@ -3085,20 +2859,6 @@ const usageReport: Command = {
|
||||
`${data.git_commits} commits`,
|
||||
].join(' · ')
|
||||
|
||||
// Build remote host info (internal-only)
|
||||
let remoteInfo = ''
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
if (remoteStats && remoteStats.totalCopied > 0) {
|
||||
const hsNames = remoteStats.hosts
|
||||
.filter(h => h.sessionCount > 0)
|
||||
.map(h => h.name)
|
||||
.join(', ')
|
||||
remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n`
|
||||
} else if (!collectRemote && hasRemoteHosts) {
|
||||
// Suggest using --homespaces if they have remote hosts but didn't use the flag
|
||||
remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n`
|
||||
}
|
||||
}
|
||||
|
||||
// Build markdown summary from insights
|
||||
const atAGlance = insights.at_a_glance
|
||||
@@ -3118,7 +2878,6 @@ ${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitiou
|
||||
|
||||
${stats}
|
||||
${data.date_range.start} to ${data.date_range.end}
|
||||
${remoteInfo}
|
||||
`
|
||||
|
||||
const userSummary = `${header}${summaryText}
|
||||
|
||||
@@ -37,8 +37,6 @@ export const ALL_AGENT_DISALLOWED_TOOLS = new Set([
|
||||
TASK_OUTPUT_TOOL_NAME,
|
||||
EXIT_PLAN_MODE_V2_TOOL_NAME,
|
||||
ENTER_PLAN_MODE_TOOL_NAME,
|
||||
// Allow Agent tool for agents when user is ant (enables nested agents)
|
||||
...(process.env.USER_TYPE === 'ant' ? [] : [AGENT_TOOL_NAME]),
|
||||
ASK_USER_QUESTION_TOOL_NAME,
|
||||
TASK_STOP_TOOL_NAME,
|
||||
// Prevent recursive workflow execution inside subagents.
|
||||
|
||||
@@ -481,16 +481,16 @@ export const CLEAR_TAB_STATUS = osc(
|
||||
)
|
||||
|
||||
/**
|
||||
* Gate for emitting OSC 21337 (tab-status indicator). Ant-only while the
|
||||
* spec is unstable. Terminals that don't recognize it discard silently, so
|
||||
* emission is safe unconditionally — we don't gate on terminal detection
|
||||
* Gate for emitting OSC 21337 (tab-status indicator). Currently disabled
|
||||
* (spec is unstable). Terminals that don't recognize it discard silently,
|
||||
* so emission is safe unconditionally — we don't gate on terminal detection
|
||||
* since support is expected across several terminals.
|
||||
*
|
||||
* Callers must wrap output with wrapForMultiplexer() so tmux/screen
|
||||
* DCS-passthrough carries the sequence to the outer terminal.
|
||||
*/
|
||||
export function supportsTabStatus(): boolean {
|
||||
return process.env.USER_TYPE === 'ant'
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,7 +109,6 @@ const externalTips: Tip[] = [
|
||||
`Use Plan Mode to prepare for a complex request before making changes. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to enable.`,
|
||||
cooldownSessions: 5,
|
||||
isRelevant: async () => {
|
||||
if (process.env.USER_TYPE === 'ant') return false
|
||||
const config = getGlobalConfig()
|
||||
// Show to users who haven't used plan mode recently (7+ days)
|
||||
const daysSinceLastUse = config.lastPlanModeUse
|
||||
@@ -401,9 +400,7 @@ const externalTips: Tip[] = [
|
||||
{
|
||||
id: 'shift-tab',
|
||||
content: async () =>
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode and auto mode`
|
||||
: `Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
|
||||
`Hit ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} to cycle between default mode, auto-accept edit mode, and plan mode`,
|
||||
cooldownSessions: 10,
|
||||
isRelevant: async () => true,
|
||||
},
|
||||
@@ -476,7 +473,6 @@ const externalTips: Tip[] = [
|
||||
`Your default model setting is Opus Plan Mode. Press ${getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')} twice to activate Plan Mode and plan with Claude Opus.`,
|
||||
cooldownSessions: 2,
|
||||
async isRelevant() {
|
||||
if (process.env.USER_TYPE === 'ant') return false
|
||||
const config = getGlobalConfig()
|
||||
const modelSetting = getUserSpecifiedModelSetting()
|
||||
const hasOpusPlanMode = modelSetting === 'opusplan'
|
||||
@@ -624,33 +620,12 @@ const externalTips: Tip[] = [
|
||||
content: async () => 'Use /feedback to help us improve!',
|
||||
cooldownSessions: 15,
|
||||
async isRelevant() {
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return false
|
||||
}
|
||||
const config = getGlobalConfig()
|
||||
return config.numStartups > 5
|
||||
},
|
||||
},
|
||||
]
|
||||
const internalOnlyTips: Tip[] =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? [
|
||||
{
|
||||
id: 'important-claudemd',
|
||||
content: async () =>
|
||||
'[internal] Use "IMPORTANT:" prefix for must-follow CLAUDE.md rules',
|
||||
cooldownSessions: 30,
|
||||
isRelevant: async () => true,
|
||||
},
|
||||
{
|
||||
id: 'skillify',
|
||||
content: async () =>
|
||||
'[internal] Use /skillify to turn repeatable recurring workflows into reusable project skills',
|
||||
cooldownSessions: 15,
|
||||
isRelevant: async () => true,
|
||||
},
|
||||
]
|
||||
: []
|
||||
const internalOnlyTips: Tip[] = []
|
||||
|
||||
function getCustomTips(): Tip[] {
|
||||
const settings = getInitialSettings()
|
||||
|
||||
17
src/tools.ts
17
src/tools.ts
@@ -12,16 +12,9 @@ import { WebFetchTool } from './tools/WebFetchTool/WebFetchTool.js'
|
||||
import { TaskStopTool } from './tools/TaskStopTool/TaskStopTool.js'
|
||||
import { BriefTool } from './tools/BriefTool/BriefTool.js'
|
||||
// Dead code elimination: conditional import for internal-only tools
|
||||
/* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */
|
||||
const REPLTool =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? require('./tools/REPLTool/REPLTool.js').REPLTool
|
||||
: null
|
||||
const SuggestBackgroundPRTool =
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? require('./tools/SuggestBackgroundPRTool/SuggestBackgroundPRTool.js')
|
||||
.SuggestBackgroundPRTool
|
||||
: null
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const REPLTool = null
|
||||
const SuggestBackgroundPRTool = null
|
||||
const SleepTool =
|
||||
feature('PROACTIVE') || feature('KAIROS')
|
||||
? require('./tools/SleepTool/SleepTool.js').SleepTool
|
||||
@@ -55,7 +48,6 @@ import { TodoWriteTool } from './tools/TodoWriteTool/TodoWriteTool.js'
|
||||
import { ExitPlanModeV2Tool } from './tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'
|
||||
import { TestingPermissionTool } from './tools/testing/TestingPermissionTool.js'
|
||||
import { GrepTool } from './tools/GrepTool/GrepTool.js'
|
||||
import { TungstenTool } from './tools/TungstenTool/TungstenTool.js'
|
||||
// Lazy require to break circular dependency: tools.ts -> TeamCreateTool/TeamDeleteTool -> ... -> tools.ts
|
||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
||||
const getTeamCreateTool = () =>
|
||||
@@ -76,7 +68,6 @@ import { ToolSearchTool } from './tools/ToolSearchTool/ToolSearchTool.js'
|
||||
import { EnterPlanModeTool } from './tools/EnterPlanModeTool/EnterPlanModeTool.js'
|
||||
import { EnterWorktreeTool } from './tools/EnterWorktreeTool/EnterWorktreeTool.js'
|
||||
import { ExitWorktreeTool } from './tools/ExitWorktreeTool/ExitWorktreeTool.js'
|
||||
import { ConfigTool } from './tools/ConfigTool/ConfigTool.js'
|
||||
import { TaskCreateTool } from './tools/TaskCreateTool/TaskCreateTool.js'
|
||||
import { TaskGetTool } from './tools/TaskGetTool/TaskGetTool.js'
|
||||
import { TaskUpdateTool } from './tools/TaskUpdateTool/TaskUpdateTool.js'
|
||||
@@ -209,8 +200,6 @@ export function getAllBaseTools(): Tools {
|
||||
AskUserQuestionTool,
|
||||
SkillTool,
|
||||
EnterPlanModeTool,
|
||||
...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
|
||||
...(process.env.USER_TYPE === 'ant' ? [TungstenTool] : []),
|
||||
...(SuggestBackgroundPRTool ? [SuggestBackgroundPRTool] : []),
|
||||
...(WebBrowserTool ? [WebBrowserTool] : []),
|
||||
...(isTodoV2Enabled()
|
||||
|
||||
@@ -73,9 +73,8 @@ export const EXPLORE_AGENT: BuiltInAgentDefinition = {
|
||||
],
|
||||
source: 'built-in',
|
||||
baseDir: 'built-in',
|
||||
// Ants get inherit to use the main agent's model; external users get haiku for speed
|
||||
// Note: For ants, getAgentModel() checks tengu_explore_agent GrowthBook flag at runtime
|
||||
model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
|
||||
// Use haiku for speed — explore is a fast read-only search agent
|
||||
model: 'haiku',
|
||||
// Explore is a fast read-only search agent — it doesn't need commit/PR/lint
|
||||
// rules from CLAUDE.md. The main agent has full context and interprets results.
|
||||
omitClaudeMd: true,
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
DOCKER_READ_ONLY_COMMANDS,
|
||||
EXTERNAL_READONLY_COMMANDS,
|
||||
type FlagArgType,
|
||||
GH_READ_ONLY_COMMANDS,
|
||||
GIT_READ_ONLY_COMMANDS,
|
||||
PYRIGHT_READ_ONLY_COMMANDS,
|
||||
RIPGREP_READ_ONLY_COMMANDS,
|
||||
@@ -1136,68 +1135,6 @@ const COMMAND_ALLOWLIST: Record<string, CommandConfig> = {
|
||||
...DOCKER_READ_ONLY_COMMANDS,
|
||||
}
|
||||
|
||||
// gh commands are internal-only since they make network requests, which goes against
|
||||
// the read-only validation principle of no network access
|
||||
const ANT_ONLY_COMMAND_ALLOWLIST: Record<string, CommandConfig> = {
|
||||
// All gh read-only commands from shared validation map
|
||||
...GH_READ_ONLY_COMMANDS,
|
||||
// aki — internal knowledge-base search CLI.
|
||||
// Network read-only (same policy as gh). --audit-csv omitted: writes to disk.
|
||||
aki: {
|
||||
safeFlags: {
|
||||
'-h': 'none',
|
||||
'--help': 'none',
|
||||
'-k': 'none',
|
||||
'--keyword': 'none',
|
||||
'-s': 'none',
|
||||
'--semantic': 'none',
|
||||
'--no-adaptive': 'none',
|
||||
'-n': 'number',
|
||||
'--limit': 'number',
|
||||
'-o': 'number',
|
||||
'--offset': 'number',
|
||||
'--source': 'string',
|
||||
'--exclude-source': 'string',
|
||||
'-a': 'string',
|
||||
'--after': 'string',
|
||||
'-b': 'string',
|
||||
'--before': 'string',
|
||||
'--collection': 'string',
|
||||
'--drive': 'string',
|
||||
'--folder': 'string',
|
||||
'--descendants': 'none',
|
||||
'-m': 'string',
|
||||
'--meta': 'string',
|
||||
'-t': 'string',
|
||||
'--threshold': 'string',
|
||||
'--kw-weight': 'string',
|
||||
'--sem-weight': 'string',
|
||||
'-j': 'none',
|
||||
'--json': 'none',
|
||||
'-c': 'none',
|
||||
'--chunk': 'none',
|
||||
'--preview': 'none',
|
||||
'-d': 'none',
|
||||
'--full-doc': 'none',
|
||||
'-v': 'none',
|
||||
'--verbose': 'none',
|
||||
'--stats': 'none',
|
||||
'-S': 'number',
|
||||
'--summarize': 'number',
|
||||
'--explain': 'none',
|
||||
'--examine': 'string',
|
||||
'--url': 'string',
|
||||
'--multi-turn': 'number',
|
||||
'--multi-turn-model': 'string',
|
||||
'--multi-turn-context': 'string',
|
||||
'--no-rerank': 'none',
|
||||
'--audit': 'none',
|
||||
'--local': 'none',
|
||||
'--staging': 'none',
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function getCommandAllowlist(): Record<string, CommandConfig> {
|
||||
let allowlist: Record<string, CommandConfig> = COMMAND_ALLOWLIST
|
||||
// On Windows, xargs can be used as a data-to-code bridge: if a file contains
|
||||
@@ -1208,9 +1145,6 @@ function getCommandAllowlist(): Record<string, CommandConfig> {
|
||||
const { xargs: _, ...rest } = allowlist
|
||||
allowlist = rest
|
||||
}
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
return { ...allowlist, ...ANT_ONLY_COMMAND_ALLOWLIST }
|
||||
}
|
||||
return allowlist
|
||||
}
|
||||
|
||||
|
||||
@@ -19,34 +19,43 @@ type SandboxInput = {
|
||||
// It is not a security bug to be able to bypass excludedCommands — the sandbox permission
|
||||
// system (which prompts users) is the actual security control.
|
||||
function containsExcludedCommand(command: string): boolean {
|
||||
// Check dynamic config for disabled commands and substrings (only for ants)
|
||||
if (process.env.USER_TYPE === 'ant') {
|
||||
const disabledCommands = getFeatureValue_CACHED_MAY_BE_STALE<{
|
||||
commands: string[]
|
||||
substrings: string[]
|
||||
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
|
||||
// Check dynamic config for disabled commands and substrings
|
||||
const raw = getFeatureValue_CACHED_MAY_BE_STALE<{
|
||||
commands: string[]
|
||||
substrings: string[]
|
||||
}>('tengu_sandbox_disabled_commands', { commands: [], substrings: [] })
|
||||
|
||||
// Check if command contains any disabled substrings
|
||||
for (const substring of disabledCommands.substrings) {
|
||||
if (command.includes(substring)) {
|
||||
const disabledCommands =
|
||||
typeof raw === 'object' && raw !== null
|
||||
? raw
|
||||
: { commands: [], substrings: [] }
|
||||
const substrings = Array.isArray(disabledCommands.substrings)
|
||||
? disabledCommands.substrings
|
||||
: []
|
||||
const commands = Array.isArray(disabledCommands.commands)
|
||||
? disabledCommands.commands
|
||||
: []
|
||||
|
||||
// Check if command contains any disabled substrings
|
||||
for (const substring of substrings) {
|
||||
if (command.includes(substring)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if command starts with any disabled commands
|
||||
try {
|
||||
const commandParts = splitCommand_DEPRECATED(command)
|
||||
for (const part of commandParts) {
|
||||
const baseCommand = part.trim().split(' ')[0]
|
||||
if (baseCommand && commands.includes(baseCommand)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if command starts with any disabled commands
|
||||
try {
|
||||
const commandParts = splitCommand_DEPRECATED(command)
|
||||
for (const part of commandParts) {
|
||||
const baseCommand = part.trim().split(' ')[0]
|
||||
if (baseCommand && disabledCommands.commands.includes(baseCommand)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the command (e.g., malformed bash syntax),
|
||||
// treat it as not excluded to allow other validation checks to handle it
|
||||
// This prevents crashes when rendering tool use messages
|
||||
}
|
||||
} catch {
|
||||
// If we can't parse the command (e.g., malformed bash syntax),
|
||||
// treat it as not excluded to allow other validation checks to handle it
|
||||
// This prevents crashes when rendering tool use messages
|
||||
}
|
||||
|
||||
// Check user-configured excluded commands from settings
|
||||
|
||||
@@ -98,73 +98,6 @@ User: "What files handle routing?"
|
||||
`
|
||||
}
|
||||
|
||||
function getEnterPlanModeToolPromptAnt(): string {
|
||||
// When interview phase is enabled, omit the "What Happens" section —
|
||||
// detailed workflow instructions arrive via the plan_mode attachment (messages.ts).
|
||||
const whatHappens = isPlanModeInterviewPhaseEnabled()
|
||||
? ''
|
||||
: WHAT_HAPPENS_SECTION
|
||||
|
||||
return `Use this tool when a task has genuine ambiguity about the right approach and getting user input before coding would prevent significant rework. This tool transitions you into plan mode where you can explore the codebase and design an implementation approach for user approval.
|
||||
|
||||
## When to Use This Tool
|
||||
|
||||
Plan mode is valuable when the implementation approach is genuinely unclear. Use it when:
|
||||
|
||||
1. **Significant Architectural Ambiguity**: Multiple reasonable approaches exist and the choice meaningfully affects the codebase
|
||||
- Example: "Add caching to the API" - Redis vs in-memory vs file-based
|
||||
- Example: "Add real-time updates" - WebSockets vs SSE vs polling
|
||||
|
||||
2. **Unclear Requirements**: You need to explore and clarify before you can make progress
|
||||
- Example: "Make the app faster" - need to profile and identify bottlenecks
|
||||
- Example: "Refactor this module" - need to understand what the target architecture should be
|
||||
|
||||
3. **High-Impact Restructuring**: The task will significantly restructure existing code and getting buy-in first reduces risk
|
||||
- Example: "Redesign the authentication system"
|
||||
- Example: "Migrate from one state management approach to another"
|
||||
|
||||
## When NOT to Use This Tool
|
||||
|
||||
Skip plan mode when you can reasonably infer the right approach:
|
||||
- The task is straightforward even if it touches multiple files
|
||||
- The user's request is specific enough that the implementation path is clear
|
||||
- You're adding a feature with an obvious implementation pattern (e.g., adding a button, a new endpoint following existing conventions)
|
||||
- Bug fixes where the fix is clear once you understand the bug
|
||||
- Research/exploration tasks (use the Agent tool instead)
|
||||
- The user says something like "can we work on X" or "let's do X" — just get started
|
||||
|
||||
When in doubt, prefer starting work and using ${ASK_USER_QUESTION_TOOL_NAME} for specific questions over entering a full planning phase.
|
||||
|
||||
${whatHappens}## Examples
|
||||
|
||||
### GOOD - Use EnterPlanMode:
|
||||
User: "Add user authentication to the app"
|
||||
- Genuinely ambiguous: session vs JWT, where to store tokens, middleware structure
|
||||
|
||||
User: "Redesign the data pipeline"
|
||||
- Major restructuring where the wrong approach wastes significant effort
|
||||
|
||||
### BAD - Don't use EnterPlanMode:
|
||||
User: "Add a delete button to the user profile"
|
||||
- Implementation path is clear; just do it
|
||||
|
||||
User: "Can we work on the search feature?"
|
||||
- User wants to get started, not plan
|
||||
|
||||
User: "Update the error handling in the API"
|
||||
- Start working; ask specific questions if needed
|
||||
|
||||
User: "Fix the typo in the README"
|
||||
- Straightforward, no planning needed
|
||||
|
||||
## Important Notes
|
||||
|
||||
- This tool REQUIRES user approval - they must consent to entering plan mode
|
||||
`
|
||||
}
|
||||
|
||||
export function getEnterPlanModeToolPrompt(): string {
|
||||
return process.env.USER_TYPE === 'ant'
|
||||
? getEnterPlanModeToolPromptAnt()
|
||||
: getEnterPlanModeToolPromptExternal()
|
||||
return getEnterPlanModeToolPromptExternal()
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function parseEffortValue(value: unknown): EffortValue | undefined {
|
||||
|
||||
/**
|
||||
* Numeric values are model-default only and not persisted.
|
||||
* 'max' is session-scoped for external users (ants can persist it).
|
||||
* 'max' can now be persisted by all users.
|
||||
* Write sites call this before saving to settings so the Zod schema
|
||||
* (which only accepts string levels) never rejects a write.
|
||||
*/
|
||||
@@ -153,15 +153,15 @@ export function toPersistableEffort(
|
||||
if (value === 'low' || value === 'medium' || value === 'high') {
|
||||
return value
|
||||
}
|
||||
if (value === 'max' && process.env.USER_TYPE === 'ant') {
|
||||
if (value === 'max') {
|
||||
return value
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function getInitialEffortSetting(): EffortLevel | undefined {
|
||||
// toPersistableEffort filters 'max' for non-ants on read, so a manually
|
||||
// edited settings.json doesn't leak session-scoped max into a fresh session.
|
||||
// toPersistableEffort validates 'max' on read, so a manually
|
||||
// edited settings.json with an invalid level doesn't leak into a fresh session.
|
||||
return toPersistableEffort(getInitialSettings().effortLevel)
|
||||
}
|
||||
|
||||
|
||||
@@ -107,15 +107,15 @@ export function _resetTmuxControlModeProbeForTesting(): void {
|
||||
|
||||
/**
|
||||
* Whether fullscreen (flicker-free) mode is enabled. Env var takes highest
|
||||
* precedence, then the `flickerFreeMode` config setting, then the internal-only
|
||||
* default. External users can enable via `/config` instead of setting the env.
|
||||
* precedence, then the `flickerFreeMode` config setting, then defaults to off.
|
||||
* Users can enable via `/config` instead of setting the env.
|
||||
*
|
||||
* Priority order:
|
||||
* CLAUDE_CODE_NO_FLICKER=0 → always off
|
||||
* CLAUDE_CODE_NO_FLICKER=1 → always on (overrides tmux -CC guard too)
|
||||
* tmux -CC detected → off (corrupts terminal state)
|
||||
* config flickerFreeMode → on/off per user preference
|
||||
* USER_TYPE=ant → on by default for internal users
|
||||
* default → off
|
||||
*/
|
||||
export function isFullscreenEnvEnabled(): boolean {
|
||||
// Explicit env opt-out always wins.
|
||||
@@ -137,7 +137,7 @@ export function isFullscreenEnvEnabled(): boolean {
|
||||
// `/config` without having to set an env var.
|
||||
const configValue = getGlobalConfig().flickerFreeMode
|
||||
if (configValue !== undefined) return configValue
|
||||
return process.env.USER_TYPE === 'ant'
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -45,12 +45,9 @@ export function getPlanModeV2ExploreAgentCount(): number {
|
||||
/**
|
||||
* Check if plan mode interview phase is enabled.
|
||||
*
|
||||
* Config: ant=always_on, external=tengu_plan_mode_interview_phase gate, envVar=true
|
||||
* Config: tengu_plan_mode_interview_phase gate, envVar=true
|
||||
*/
|
||||
export function isPlanModeInterviewPhaseEnabled(): boolean {
|
||||
// Always on for ants
|
||||
if (process.env.USER_TYPE === 'ant') return true
|
||||
|
||||
const env = process.env.CLAUDE_CODE_PLAN_MODE_INTERVIEW_PHASE
|
||||
if (isEnvTruthy(env)) return true
|
||||
if (isEnvDefinedFalsy(env)) return false
|
||||
|
||||
@@ -714,11 +714,7 @@ export const SettingsSchema = lazySchema(() =>
|
||||
'enabled automatically for supported models.',
|
||||
),
|
||||
effortLevel: z
|
||||
.enum(
|
||||
process.env.USER_TYPE === 'ant'
|
||||
? ['low', 'medium', 'high', 'max']
|
||||
: ['low', 'medium', 'high'],
|
||||
)
|
||||
.enum(['low', 'medium', 'high', 'max'])
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.describe('Persisted effort level for supported models.'),
|
||||
|
||||
Reference in New Issue
Block a user