source dump of claude code
at main 447 lines 19 kB view raw
1import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 2import type { MCPServerConnection } from '../../services/mcp/types.js' 3import { isPolicyAllowed } from '../../services/policyLimits/index.js' 4import type { ToolUseContext } from '../../Tool.js' 5import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js' 6import { REMOTE_TRIGGER_TOOL_NAME } from '../../tools/RemoteTriggerTool/prompt.js' 7import { getClaudeAIOAuthTokens } from '../../utils/auth.js' 8import { checkRepoForRemoteAccess } from '../../utils/background/remote/preconditions.js' 9import { logForDebugging } from '../../utils/debug.js' 10import { 11 detectCurrentRepositoryWithHost, 12 parseGitRemote, 13} from '../../utils/detectRepository.js' 14import { getRemoteUrl } from '../../utils/git.js' 15import { jsonStringify } from '../../utils/slowOperations.js' 16import { 17 createDefaultCloudEnvironment, 18 type EnvironmentResource, 19 fetchEnvironments, 20} from '../../utils/teleport/environments.js' 21import { registerBundledSkill } from '../bundledSkills.js' 22 23// Base58 alphabet (Bitcoin-style) used by the tagged ID system 24const BASE58 = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 25 26/** 27 * Decode a mcpsrv_ tagged ID to a UUID string. 28 * Tagged IDs have format: mcpsrv_01{base58(uuid.int)} 29 * where 01 is the version prefix. 30 * 31 * TODO(public-ship): Before shipping publicly, the /v1/mcp_servers endpoint 32 * should return the raw UUID directly so we don't need this client-side decoding. 33 * The tagged ID format is an internal implementation detail that could change. 34 */ 35function taggedIdToUUID(taggedId: string): string | null { 36 const prefix = 'mcpsrv_' 37 if (!taggedId.startsWith(prefix)) { 38 return null 39 } 40 const rest = taggedId.slice(prefix.length) 41 // Skip version prefix (2 chars, always "01") 42 const base58Data = rest.slice(2) 43 44 // Decode base58 to bigint 45 let n = 0n 46 for (const c of base58Data) { 47 const idx = BASE58.indexOf(c) 48 if (idx === -1) { 49 return null 50 } 51 n = n * 58n + BigInt(idx) 52 } 53 54 // Convert to UUID hex string 55 const hex = n.toString(16).padStart(32, '0') 56 return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` 57} 58 59type ConnectorInfo = { 60 uuid: string 61 name: string 62 url: string 63} 64 65function getConnectedClaudeAIConnectors( 66 mcpClients: MCPServerConnection[], 67): ConnectorInfo[] { 68 const connectors: ConnectorInfo[] = [] 69 for (const client of mcpClients) { 70 if (client.type !== 'connected') { 71 continue 72 } 73 if (client.config.type !== 'claudeai-proxy') { 74 continue 75 } 76 const uuid = taggedIdToUUID(client.config.id) 77 if (!uuid) { 78 continue 79 } 80 connectors.push({ 81 uuid, 82 name: client.name, 83 url: client.config.url, 84 }) 85 } 86 return connectors 87} 88 89function sanitizeConnectorName(name: string): string { 90 return name 91 .replace(/^claude[.\s-]ai[.\s-]/i, '') 92 .replace(/[^a-zA-Z0-9_-]/g, '-') 93 .replace(/-+/g, '-') 94 .replace(/^-|-$/g, '') 95} 96 97function formatConnectorsInfo(connectors: ConnectorInfo[]): string { 98 if (connectors.length === 0) { 99 return 'No connected MCP connectors found. The user may need to connect servers at https://claude.ai/settings/connectors' 100 } 101 const lines = ['Connected connectors (available for triggers):'] 102 for (const c of connectors) { 103 const safeName = sanitizeConnectorName(c.name) 104 lines.push( 105 `- ${c.name} (connector_uuid: ${c.uuid}, name: ${safeName}, url: ${c.url})`, 106 ) 107 } 108 return lines.join('\n') 109} 110 111const BASE_QUESTION = 'What would you like to do with scheduled remote agents?' 112 113/** 114 * Formats setup notes as a bulleted Heads-up block. Shared between the 115 * initial AskUserQuestion dialog text (no-args path) and the prompt-body 116 * section (args path) so notes are never silently dropped. 117 */ 118function formatSetupNotes(notes: string[]): string { 119 const items = notes.map(n => `- ${n}`).join('\n') 120 return `⚠ Heads-up:\n${items}` 121} 122 123async function getCurrentRepoHttpsUrl(): Promise<string | null> { 124 const remoteUrl = await getRemoteUrl() 125 if (!remoteUrl) { 126 return null 127 } 128 const parsed = parseGitRemote(remoteUrl) 129 if (!parsed) { 130 return null 131 } 132 return `https://${parsed.host}/${parsed.owner}/${parsed.name}` 133} 134 135function buildPrompt(opts: { 136 userTimezone: string 137 connectorsInfo: string 138 gitRepoUrl: string | null 139 environmentsInfo: string 140 createdEnvironment: EnvironmentResource | null 141 setupNotes: string[] 142 needsGitHubAccessReminder: boolean 143 userArgs: string 144}): string { 145 const { 146 userTimezone, 147 connectorsInfo, 148 gitRepoUrl, 149 environmentsInfo, 150 createdEnvironment, 151 setupNotes, 152 needsGitHubAccessReminder, 153 userArgs, 154 } = opts 155 // When the user passes args, the initial AskUserQuestion dialog is skipped. 156 // Setup notes must surface in the prompt body instead, otherwise they're 157 // computed and silently discarded (regression vs. the old hard-block). 158 const setupNotesSection = 159 userArgs && setupNotes.length > 0 160 ? `\n## Setup Notes\n\n${formatSetupNotes(setupNotes)}\n` 161 : '' 162 const initialQuestion = 163 setupNotes.length > 0 164 ? `${formatSetupNotes(setupNotes)}\n\n${BASE_QUESTION}` 165 : BASE_QUESTION 166 const firstStep = userArgs 167 ? `The user has already told you what they want (see User Request at the bottom). Skip the initial question and go directly to the matching workflow.` 168 : `Your FIRST action must be a single ${ASK_USER_QUESTION_TOOL_NAME} tool call (no preamble). Use this EXACT string for the \`question\` field — do not paraphrase or shorten it: 169 170${jsonStringify(initialQuestion)} 171 172Set \`header: "Action"\` and offer the four actions (create/list/update/run) as options. After the user picks, follow the matching workflow below.` 173 174 return `# Schedule Remote Agents 175 176You are helping the user schedule, update, list, or run **remote** Claude Code agents. These are NOT local cron jobs — each trigger spawns a fully isolated remote session (CCR) in Anthropic's cloud infrastructure on a cron schedule. The agent runs in a sandboxed environment with its own git checkout, tools, and optional MCP connections. 177 178## First Step 179 180${firstStep} 181${setupNotesSection} 182 183## What You Can Do 184 185Use the \`${REMOTE_TRIGGER_TOOL_NAME}\` tool (load it first with \`ToolSearch select:${REMOTE_TRIGGER_TOOL_NAME}\`; auth is handled in-process — do not use curl): 186 187- \`{action: "list"}\` — list all triggers 188- \`{action: "get", trigger_id: "..."}\` — fetch one trigger 189- \`{action: "create", body: {...}}\` — create a trigger 190- \`{action: "update", trigger_id: "...", body: {...}}\` — partial update 191- \`{action: "run", trigger_id: "..."}\` — run a trigger now 192 193You CANNOT delete triggers. If the user asks to delete, direct them to: https://claude.ai/code/scheduled 194 195## Create body shape 196 197\`\`\`json 198{ 199 "name": "AGENT_NAME", 200 "cron_expression": "CRON_EXPR", 201 "enabled": true, 202 "job_config": { 203 "ccr": { 204 "environment_id": "ENVIRONMENT_ID", 205 "session_context": { 206 "model": "claude-sonnet-4-6", 207 "sources": [ 208 {"git_repository": {"url": "${gitRepoUrl || 'https://github.com/ORG/REPO'}"}} 209 ], 210 "allowed_tools": ["Bash", "Read", "Write", "Edit", "Glob", "Grep"] 211 }, 212 "events": [ 213 {"data": { 214 "uuid": "<lowercase v4 uuid>", 215 "session_id": "", 216 "type": "user", 217 "parent_tool_use_id": null, 218 "message": {"content": "PROMPT_HERE", "role": "user"} 219 }} 220 ] 221 } 222 } 223} 224\`\`\` 225 226Generate a fresh lowercase UUID for \`events[].data.uuid\` yourself. 227 228## Available MCP Connectors 229 230These are the user's currently connected claude.ai MCP connectors: 231 232${connectorsInfo} 233 234When attaching connectors to a trigger, use the \`connector_uuid\` and \`name\` shown above (the name is already sanitized to only contain letters, numbers, hyphens, and underscores), and the connector's URL. The \`name\` field in \`mcp_connections\` must only contain \`[a-zA-Z0-9_-]\` — dots and spaces are NOT allowed. 235 236**Important:** Infer what services the agent needs from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack connectors. Cross-reference against the list above and warn if any required service isn't connected. If a needed connector is missing, direct the user to https://claude.ai/settings/connectors to connect it first. 237 238## Environments 239 240Every trigger requires an \`environment_id\` in the job config. This determines where the remote agent runs. Ask the user which environment to use. 241 242${environmentsInfo} 243 244Use the \`id\` value as the \`environment_id\` in \`job_config.ccr.environment_id\`. 245${createdEnvironment ? `\n**Note:** A new environment \`${createdEnvironment.name}\` (id: \`${createdEnvironment.environment_id}\`) was just created for the user because they had none. Use this id for \`job_config.ccr.environment_id\` and mention the creation when you confirm the trigger config.\n` : ''} 246 247## API Field Reference 248 249### Create Trigger — Required Fields 250- \`name\` (string) — A descriptive name 251- \`cron_expression\` (string) — 5-field cron. **Minimum interval is 1 hour.** 252- \`job_config\` (object) — Session configuration (see structure above) 253 254### Create Trigger — Optional Fields 255- \`enabled\` (boolean, default: true) 256- \`mcp_connections\` (array) — MCP servers to attach: 257 \`\`\`json 258 [{"connector_uuid": "uuid", "name": "server-name", "url": "https://..."}] 259 \`\`\` 260 261### Update Trigger — Optional Fields 262All fields optional (partial update): 263- \`name\`, \`cron_expression\`, \`enabled\`, \`job_config\` 264- \`mcp_connections\` — Replace MCP connections 265- \`clear_mcp_connections\` (boolean) — Remove all MCP connections 266 267### Cron Expression Examples 268 269The user's local timezone is **${userTimezone}**. Cron expressions are always in UTC. When the user says a local time, convert it to UTC for the cron expression but confirm with them: "9am ${userTimezone} = Xam UTC, so the cron would be \`0 X * * 1-5\`." 270 271- \`0 9 * * 1-5\` — Every weekday at 9am **UTC** 272- \`0 */2 * * *\` — Every 2 hours 273- \`0 0 * * *\` — Daily at midnight **UTC** 274- \`30 14 * * 1\` — Every Monday at 2:30pm **UTC** 275- \`0 8 1 * *\` — First of every month at 8am **UTC** 276 277Minimum interval is 1 hour. \`*/30 * * * *\` will be rejected. 278 279## Workflow 280 281### CREATE a new trigger: 282 2831. **Understand the goal** — Ask what they want the remote agent to do. What repo(s)? What task? Remind them that the agent runs remotely — it won't have access to their local machine, local files, or local environment variables. 2842. **Craft the prompt** — Help them write an effective agent prompt. Good prompts are: 285 - Specific about what to do and what success looks like 286 - Clear about which files/areas to focus on 287 - Explicit about what actions to take (open PRs, commit, just analyze, etc.) 2883. **Set the schedule** — Ask when and how often. The user's timezone is ${userTimezone}. When they say a time (e.g., "every morning at 9am"), assume they mean their local time and convert to UTC for the cron expression. Always confirm the conversion: "9am ${userTimezone} = Xam UTC." 2894. **Choose the model** — Default to \`claude-sonnet-4-6\`. Tell the user which model you're defaulting to and ask if they want a different one. 2905. **Validate connections** — Infer what services the agent will need from the user's description. For example, if they say "check Datadog and Slack me errors," the agent needs both Datadog and Slack MCP connectors. Cross-reference with the connectors list above. If any are missing, warn the user and link them to https://claude.ai/settings/connectors to connect first.${gitRepoUrl ? ` The default git repo is already set to \`${gitRepoUrl}\`. Ask the user if this is the right repo or if they need a different one.` : ' Ask which git repos the remote agent needs cloned into its environment.'} 2916. **Review and confirm** — Show the full configuration before creating. Let them adjust. 2927. **Create it** \u2014 Call \`${REMOTE_TRIGGER_TOOL_NAME}\` with \`action: "create"\` and show the result. The response includes the trigger ID. Always output a link at the end: \`https://claude.ai/code/scheduled/{TRIGGER_ID}\` 293 294### UPDATE a trigger: 295 2961. List triggers first so they can pick one 2972. Ask what they want to change 2983. Show current vs proposed value 2994. Confirm and update 300 301### LIST triggers: 302 3031. Fetch and display in a readable format 3042. Show: name, schedule (human-readable), enabled/disabled, next run, repo(s) 305 306### RUN NOW: 307 3081. List triggers if they haven't specified which one 3092. Confirm which trigger 3103. Execute and confirm 311 312## Important Notes 313 314- These are REMOTE agents — they run in Anthropic's cloud, not on the user's machine. They cannot access local files, local services, or local environment variables. 315- Always convert cron to human-readable when displaying 316- Default to \`enabled: true\` unless user says otherwise 317- Accept GitHub URLs in any format (https://github.com/org/repo, org/repo, etc.) and normalize to the full HTTPS URL (without .git suffix) 318- The prompt is the most important part — spend time getting it right. The remote agent starts with zero context, so the prompt must be self-contained. 319- To delete a trigger, direct users to https://claude.ai/code/scheduled 320${needsGitHubAccessReminder ? `- If the user's request seems to require GitHub repo access (e.g. cloning a repo, opening PRs, reading code), remind them that ${getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_lantern', false) ? "they should run /web-setup to connect their GitHub account (or install the Claude GitHub App on the repo as an alternative) — otherwise the remote agent won't be able to access it" : "they need the Claude GitHub App installed on the repo — otherwise the remote agent won't be able to access it"}.` : ''} 321${userArgs ? `\n## User Request\n\nThe user said: "${userArgs}"\n\nStart by understanding their intent and working through the appropriate workflow above.` : ''}` 322} 323 324export function registerScheduleRemoteAgentsSkill(): void { 325 registerBundledSkill({ 326 name: 'schedule', 327 description: 328 'Create, update, list, or run scheduled remote agents (triggers) that execute on a cron schedule.', 329 whenToUse: 330 'When the user wants to schedule a recurring remote agent, set up automated tasks, create a cron job for Claude Code, or manage their scheduled agents/triggers.', 331 userInvocable: true, 332 isEnabled: () => 333 getFeatureValue_CACHED_MAY_BE_STALE('tengu_surreal_dali', false) && 334 isPolicyAllowed('allow_remote_sessions'), 335 allowedTools: [REMOTE_TRIGGER_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME], 336 async getPromptForCommand(args: string, context: ToolUseContext) { 337 if (!getClaudeAIOAuthTokens()?.accessToken) { 338 return [ 339 { 340 type: 'text', 341 text: 'You need to authenticate with a claude.ai account first. API accounts are not supported. Run /login, then try /schedule again.', 342 }, 343 ] 344 } 345 346 let environments: EnvironmentResource[] 347 try { 348 environments = await fetchEnvironments() 349 } catch (err) { 350 logForDebugging(`[schedule] Failed to fetch environments: ${err}`, { 351 level: 'warn', 352 }) 353 return [ 354 { 355 type: 'text', 356 text: "We're having trouble connecting with your remote claude.ai account to set up a scheduled task. Please try /schedule again in a few minutes.", 357 }, 358 ] 359 } 360 361 let createdEnvironment: EnvironmentResource | null = null 362 if (environments.length === 0) { 363 try { 364 createdEnvironment = await createDefaultCloudEnvironment( 365 'claude-code-default', 366 ) 367 environments = [createdEnvironment] 368 } catch (err) { 369 logForDebugging(`[schedule] Failed to create environment: ${err}`, { 370 level: 'warn', 371 }) 372 return [ 373 { 374 type: 'text', 375 text: 'No remote environments found, and we could not create one automatically. Visit https://claude.ai/code to set one up, then run /schedule again.', 376 }, 377 ] 378 } 379 } 380 381 // Soft setup checks — collected as upfront notes embedded in the initial 382 // AskUserQuestion dialog. Never block — triggers don't require a git 383 // source (e.g., Slack-only polls), and the trigger's sources may point 384 // at a different repo than cwd anyway. 385 const setupNotes: string[] = [] 386 let needsGitHubAccessReminder = false 387 388 const repo = await detectCurrentRepositoryWithHost() 389 if (repo === null) { 390 setupNotes.push( 391 `Not in a git repo — you'll need to specify a repo URL manually (or skip repos entirely).`, 392 ) 393 } else if (repo.host === 'github.com') { 394 const { hasAccess } = await checkRepoForRemoteAccess( 395 repo.owner, 396 repo.name, 397 ) 398 if (!hasAccess) { 399 needsGitHubAccessReminder = true 400 const webSetupEnabled = getFeatureValue_CACHED_MAY_BE_STALE( 401 'tengu_cobalt_lantern', 402 false, 403 ) 404 const msg = webSetupEnabled 405 ? `GitHub not connected for ${repo.owner}/${repo.name} \u2014 run /web-setup to sync your GitHub credentials, or install the Claude GitHub App at https://claude.ai/code/onboarding?magic=github-app-setup.` 406 : `Claude GitHub App not installed on ${repo.owner}/${repo.name} \u2014 install at https://claude.ai/code/onboarding?magic=github-app-setup if your trigger needs this repo.` 407 setupNotes.push(msg) 408 } 409 } 410 // Non-github.com hosts (GHE/GitLab/etc.): silently skip. The GitHub 411 // App check is github.com-specific, and the "not in a git repo" note 412 // would be factually wrong — getCurrentRepoHttpsUrl() below will 413 // still populate gitRepoUrl with the GHE URL. 414 415 const connectors = getConnectedClaudeAIConnectors( 416 context.options.mcpClients, 417 ) 418 if (connectors.length === 0) { 419 setupNotes.push( 420 `No MCP connectors — connect at https://claude.ai/settings/connectors if needed.`, 421 ) 422 } 423 424 const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone 425 const connectorsInfo = formatConnectorsInfo(connectors) 426 const gitRepoUrl = await getCurrentRepoHttpsUrl() 427 const lines = ['Available environments:'] 428 for (const env of environments) { 429 lines.push( 430 `- ${env.name} (id: ${env.environment_id}, kind: ${env.kind})`, 431 ) 432 } 433 const environmentsInfo = lines.join('\n') 434 const prompt = buildPrompt({ 435 userTimezone, 436 connectorsInfo, 437 gitRepoUrl, 438 environmentsInfo, 439 createdEnvironment, 440 setupNotes, 441 needsGitHubAccessReminder, 442 userArgs: args, 443 }) 444 return [{ type: 'text', text: prompt }] 445 }, 446 }) 447}