流出したclaude codeのソースコード7

@Kongyokongyo / 更新: 2026/04/19 12:47
MD 1.95MB
0

File: src/utils/settings/types.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { z } from 'zod/v4' 3: import { SandboxSettingsSchema } from '../../entrypoints/sandboxTypes.js' 4: import { isEnvTruthy } from '../envUtils.js' 5: import { lazySchema } from '../lazySchema.js' 6: import { 7: EXTERNAL_PERMISSION_MODES, 8: PERMISSION_MODES, 9: } from '../permissions/PermissionMode.js' 10: import { MarketplaceSourceSchema } from '../plugins/schemas.js' 11: import { CLAUDE_CODE_SETTINGS_SCHEMA_URL } from './constants.js' 12: import { PermissionRuleSchema } from './permissionValidation.js' 13: export { 14: type AgentHook, 15: type BashCommandHook, 16: type HookCommand, 17: HookCommandSchema, 18: type HookMatcher, 19: HookMatcherSchema, 20: HooksSchema, 21: type HooksSettings, 22: type HttpHook, 23: type PromptHook, 24: } from '../../schemas/hooks.js' 25: import { type HookCommand, HooksSchema } from '../../schemas/hooks.js' 26: import { count } from '../array.js' 27: export const EnvironmentVariablesSchema = lazySchema(() => 28: z.record(z.string(), z.coerce.string()), 29: ) 30: export const PermissionsSchema = lazySchema(() => 31: z 32: .object({ 33: allow: z 34: .array(PermissionRuleSchema()) 35: .optional() 36: .describe('List of permission rules for allowed operations'), 37: deny: z 38: .array(PermissionRuleSchema()) 39: .optional() 40: .describe('List of permission rules for denied operations'), 41: ask: z 42: .array(PermissionRuleSchema()) 43: .optional() 44: .describe( 45: 'List of permission rules that should always prompt for confirmation', 46: ), 47: defaultMode: z 48: .enum( 49: feature('TRANSCRIPT_CLASSIFIER') 50: ? PERMISSION_MODES 51: : EXTERNAL_PERMISSION_MODES, 52: ) 53: .optional() 54: .describe('Default permission mode when Claude Code needs access'), 55: disableBypassPermissionsMode: z 56: .enum(['disable']) 57: .optional() 58: .describe('Disable the ability to bypass permission prompts'), 59: ...(feature('TRANSCRIPT_CLASSIFIER') 60: ? { 61: disableAutoMode: z 62: .enum(['disable']) 63: .optional() 64: .describe('Disable auto mode'), 65: } 66: : {}), 67: additionalDirectories: z 68: .array(z.string()) 69: .optional() 70: .describe('Additional directories to include in the permission scope'), 71: }) 72: .passthrough(), 73: ) 74: export const ExtraKnownMarketplaceSchema = lazySchema(() => 75: z.object({ 76: source: MarketplaceSourceSchema().describe( 77: 'Where to fetch the marketplace from', 78: ), 79: installLocation: z 80: .string() 81: .optional() 82: .describe( 83: 'Local cache path where marketplace manifest is stored (auto-generated if not provided)', 84: ), 85: autoUpdate: z 86: .boolean() 87: .optional() 88: .describe( 89: 'Whether to automatically update this marketplace and its installed plugins on startup', 90: ), 91: }), 92: ) 93: export const AllowedMcpServerEntrySchema = lazySchema(() => 94: z 95: .object({ 96: serverName: z 97: .string() 98: .regex( 99: /^[a-zA-Z0-9_-]+$/, 100: 'Server name can only contain letters, numbers, hyphens, and underscores', 101: ) 102: .optional() 103: .describe('Name of the MCP server that users are allowed to configure'), 104: serverCommand: z 105: .array(z.string()) 106: .min(1, 'Server command must have at least one element (the command)') 107: .optional() 108: .describe( 109: 'Command array [command, ...args] to match exactly for allowed stdio servers', 110: ), 111: serverUrl: z 112: .string() 113: .optional() 114: .describe( 115: 'URL pattern with wildcard support (e.g., "https://*.example.com/*") for allowed remote MCP servers', 116: ), 117: }) 118: .refine( 119: data => { 120: const defined = count( 121: [ 122: data.serverName !== undefined, 123: data.serverCommand !== undefined, 124: data.serverUrl !== undefined, 125: ], 126: Boolean, 127: ) 128: return defined === 1 129: }, 130: { 131: message: 132: 'Entry must have exactly one of "serverName", "serverCommand", or "serverUrl"', 133: }, 134: ), 135: ) 136: export const DeniedMcpServerEntrySchema = lazySchema(() => 137: z 138: .object({ 139: serverName: z 140: .string() 141: .regex( 142: /^[a-zA-Z0-9_-]+$/, 143: 'Server name can only contain letters, numbers, hyphens, and underscores', 144: ) 145: .optional() 146: .describe('Name of the MCP server that is explicitly blocked'), 147: serverCommand: z 148: .array(z.string()) 149: .min(1, 'Server command must have at least one element (the command)') 150: .optional() 151: .describe( 152: 'Command array [command, ...args] to match exactly for blocked stdio servers', 153: ), 154: serverUrl: z 155: .string() 156: .optional() 157: .describe( 158: 'URL pattern with wildcard support (e.g., "https://*.example.com/*") for blocked remote MCP servers', 159: ), 160: }) 161: .refine( 162: data => { 163: const defined = count( 164: [ 165: data.serverName !== undefined, 166: data.serverCommand !== undefined, 167: data.serverUrl !== undefined, 168: ], 169: Boolean, 170: ) 171: return defined === 1 172: }, 173: { 174: message: 175: 'Entry must have exactly one of "serverName", "serverCommand", or "serverUrl"', 176: }, 177: ), 178: ) 179: export const CUSTOMIZATION_SURFACES = [ 180: 'skills', 181: 'agents', 182: 'hooks', 183: 'mcp', 184: ] as const 185: export const SettingsSchema = lazySchema(() => 186: z 187: .object({ 188: $schema: z 189: .literal(CLAUDE_CODE_SETTINGS_SCHEMA_URL) 190: .optional() 191: .describe('JSON Schema reference for Claude Code settings'), 192: apiKeyHelper: z 193: .string() 194: .optional() 195: .describe('Path to a script that outputs authentication values'), 196: awsCredentialExport: z 197: .string() 198: .optional() 199: .describe('Path to a script that exports AWS credentials'), 200: awsAuthRefresh: z 201: .string() 202: .optional() 203: .describe('Path to a script that refreshes AWS authentication'), 204: gcpAuthRefresh: z 205: .string() 206: .optional() 207: .describe( 208: 'Command to refresh GCP authentication (e.g., gcloud auth application-default login)', 209: ), 210: ...(isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_XAA) 211: ? { 212: xaaIdp: z 213: .object({ 214: issuer: z 215: .string() 216: .url() 217: .describe('IdP issuer URL for OIDC discovery'), 218: clientId: z 219: .string() 220: .describe("Claude Code's client_id registered at the IdP"), 221: callbackPort: z 222: .number() 223: .int() 224: .positive() 225: .optional() 226: .describe( 227: 'Fixed loopback callback port for the IdP OIDC login. ' + 228: 'Only needed if the IdP does not honor RFC 8252 port-any matching.', 229: ), 230: }) 231: .optional() 232: .describe( 233: 'XAA (SEP-990) IdP connection. Configure once; all XAA-enabled MCP servers reuse this.', 234: ), 235: } 236: : {}), 237: fileSuggestion: z 238: .object({ 239: type: z.literal('command'), 240: command: z.string(), 241: }) 242: .optional() 243: .describe('Custom file suggestion configuration for @ mentions'), 244: respectGitignore: z 245: .boolean() 246: .optional() 247: .describe( 248: 'Whether file picker should respect .gitignore files (default: true). ' + 249: 'Note: .ignore files are always respected.', 250: ), 251: cleanupPeriodDays: z 252: .number() 253: .nonnegative() 254: .int() 255: .optional() 256: .describe( 257: 'Number of days to retain chat transcripts (default: 30). Setting to 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.', 258: ), 259: env: EnvironmentVariablesSchema() 260: .optional() 261: .describe('Environment variables to set for Claude Code sessions'), 262: attribution: z 263: .object({ 264: commit: z 265: .string() 266: .optional() 267: .describe( 268: 'Attribution text for git commits, including any trailers. ' + 269: 'Empty string hides attribution.', 270: ), 271: pr: z 272: .string() 273: .optional() 274: .describe( 275: 'Attribution text for pull request descriptions. ' + 276: 'Empty string hides attribution.', 277: ), 278: }) 279: .optional() 280: .describe( 281: 'Customize attribution text for commits and PRs. ' + 282: 'Each field defaults to the standard Claude Code attribution if not set.', 283: ), 284: includeCoAuthoredBy: z 285: .boolean() 286: .optional() 287: .describe( 288: 'Deprecated: Use attribution instead. ' + 289: "Whether to include Claude's co-authored by attribution in commits and PRs (defaults to true)", 290: ), 291: includeGitInstructions: z 292: .boolean() 293: .optional() 294: .describe( 295: "Include built-in commit and PR workflow instructions in Claude's system prompt (default: true)", 296: ), 297: permissions: PermissionsSchema() 298: .optional() 299: .describe('Tool usage permissions configuration'), 300: model: z 301: .string() 302: .optional() 303: .describe('Override the default model used by Claude Code'), 304: availableModels: z 305: .array(z.string()) 306: .optional() 307: .describe( 308: 'Allowlist of models that users can select. ' + 309: 'Accepts family aliases ("opus" allows any opus version), ' + 310: 'version prefixes ("opus-4-5" allows only that version), ' + 311: 'and full model IDs. ' + 312: 'If undefined, all models are available. If empty array, only the default model is available. ' + 313: 'Typically set in managed settings by enterprise administrators.', 314: ), 315: modelOverrides: z 316: .record(z.string(), z.string()) 317: .optional() 318: .describe( 319: 'Override mapping from Anthropic model ID (e.g. "claude-opus-4-6") to provider-specific ' + 320: 'model ID (e.g. a Bedrock inference profile ARN). Typically set in managed settings by ' + 321: 'enterprise administrators.', 322: ), 323: enableAllProjectMcpServers: z 324: .boolean() 325: .optional() 326: .describe( 327: 'Whether to automatically approve all MCP servers in the project', 328: ), 329: enabledMcpjsonServers: z 330: .array(z.string()) 331: .optional() 332: .describe('List of approved MCP servers from .mcp.json'), 333: disabledMcpjsonServers: z 334: .array(z.string()) 335: .optional() 336: .describe('List of rejected MCP servers from .mcp.json'), 337: allowedMcpServers: z 338: .array(AllowedMcpServerEntrySchema()) 339: .optional() 340: .describe( 341: 'Enterprise allowlist of MCP servers that can be used. ' + 342: 'Applies to all scopes including enterprise servers from managed-mcp.json. ' + 343: 'If undefined, all servers are allowed. If empty array, no servers are allowed. ' + 344: 'Denylist takes precedence - if a server is on both lists, it is denied.', 345: ), 346: deniedMcpServers: z 347: .array(DeniedMcpServerEntrySchema()) 348: .optional() 349: .describe( 350: 'Enterprise denylist of MCP servers that are explicitly blocked. ' + 351: 'If a server is on the denylist, it will be blocked across all scopes including enterprise. ' + 352: 'Denylist takes precedence over allowlist - if a server is on both lists, it is denied.', 353: ), 354: hooks: HooksSchema() 355: .optional() 356: .describe('Custom commands to run before/after tool executions'), 357: worktree: z 358: .object({ 359: symlinkDirectories: z 360: .array(z.string()) 361: .optional() 362: .describe( 363: 'Directories to symlink from main repository to worktrees to avoid disk bloat. ' + 364: 'Must be explicitly configured - no directories are symlinked by default. ' + 365: 'Common examples: "node_modules", ".cache", ".bin"', 366: ), 367: sparsePaths: z 368: .array(z.string()) 369: .optional() 370: .describe( 371: 'Directories to include when creating worktrees, via git sparse-checkout (cone mode). ' + 372: 'Dramatically faster in large monorepos — only the listed paths are written to disk.', 373: ), 374: }) 375: .optional() 376: .describe('Git worktree configuration for --worktree flag.'), 377: disableAllHooks: z 378: .boolean() 379: .optional() 380: .describe('Disable all hooks and statusLine execution'), 381: defaultShell: z 382: .enum(['bash', 'powershell']) 383: .optional() 384: .describe( 385: 'Default shell for input-box ! commands. ' + 386: "Defaults to 'bash' on all platforms (no Windows auto-flip).", 387: ), 388: allowManagedHooksOnly: z 389: .boolean() 390: .optional() 391: .describe( 392: 'When true (and set in managed settings), only hooks from managed settings run. ' + 393: 'User, project, and local hooks are ignored.', 394: ), 395: allowedHttpHookUrls: z 396: .array(z.string()) 397: .optional() 398: .describe( 399: 'Allowlist of URL patterns that HTTP hooks may target. ' + 400: 'Supports * as a wildcard (e.g. "https://hooks.example.com/*"). ' + 401: 'When set, HTTP hooks with non-matching URLs are blocked. ' + 402: 'If undefined, all URLs are allowed. If empty array, no HTTP hooks are allowed. ' + 403: 'Arrays merge across settings sources (same semantics as allowedMcpServers).', 404: ), 405: httpHookAllowedEnvVars: z 406: .array(z.string()) 407: .optional() 408: .describe( 409: 'Allowlist of environment variable names HTTP hooks may interpolate into headers. ' + 410: "When set, each hook's effective allowedEnvVars is the intersection with this list. " + 411: 'If undefined, no restriction is applied. ' + 412: 'Arrays merge across settings sources (same semantics as allowedMcpServers).', 413: ), 414: allowManagedPermissionRulesOnly: z 415: .boolean() 416: .optional() 417: .describe( 418: 'When true (and set in managed settings), only permission rules (allow/deny/ask) from managed settings are respected. ' + 419: 'User, project, local, and CLI argument permission rules are ignored.', 420: ), 421: allowManagedMcpServersOnly: z 422: .boolean() 423: .optional() 424: .describe( 425: 'When true (and set in managed settings), allowedMcpServers is only read from managed settings. ' + 426: 'deniedMcpServers still merges from all sources, so users can deny servers for themselves. ' + 427: 'Users can still add their own MCP servers, but only the admin-defined allowlist applies.', 428: ), 429: strictPluginOnlyCustomization: z 430: .preprocess( 431: v => 432: Array.isArray(v) 433: ? v.filter(x => 434: (CUSTOMIZATION_SURFACES as readonly string[]).includes(x), 435: ) 436: : v, 437: z.union([z.boolean(), z.array(z.enum(CUSTOMIZATION_SURFACES))]), 438: ) 439: .optional() 440: .catch(undefined) 441: .describe( 442: 'When set in managed settings, blocks non-plugin customization sources for the listed surfaces. ' + 443: 'Array form locks specific surfaces (e.g. ["skills", "hooks"]); `true` locks all four; `false` is an explicit no-op. ' + 444: 'Blocked: ~/.claude/{surface}/, .claude/{surface}/ (project), settings.json hooks, .mcp.json. ' + 445: 'NOT blocked: managed (policySettings) sources, plugin-provided customizations. ' + 446: 'Composes with strictKnownMarketplaces for end-to-end admin control — plugins gated by ' + 447: 'marketplace allowlist, everything else blocked here.', 448: ), 449: statusLine: z 450: .object({ 451: type: z.literal('command'), 452: command: z.string(), 453: padding: z.number().optional(), 454: }) 455: .optional() 456: .describe('Custom status line display configuration'), 457: enabledPlugins: z 458: .record( 459: z.string(), 460: z.union([z.array(z.string()), z.boolean(), z.undefined()]), 461: ) 462: .optional() 463: .describe( 464: 'Enabled plugins using plugin-id@marketplace-id format. Example: { "formatter@anthropic-tools": true }. Also supports extended format with version constraints.', 465: ), 466: extraKnownMarketplaces: z 467: .record(z.string(), ExtraKnownMarketplaceSchema()) 468: .check(ctx => { 469: for (const [key, entry] of Object.entries(ctx.value)) { 470: if ( 471: entry.source.source === 'settings' && 472: entry.source.name !== key 473: ) { 474: ctx.issues.push({ 475: code: 'custom', 476: input: entry.source.name, 477: path: [key, 'source', 'name'], 478: message: 479: `Settings-sourced marketplace name must match its extraKnownMarketplaces key ` + 480: `(got key "${key}" but source.name "${entry.source.name}")`, 481: }) 482: } 483: } 484: }) 485: .optional() 486: .describe( 487: 'Additional marketplaces to make available for this repository. Typically used in repository .claude/settings.json to ensure team members have required plugin sources.', 488: ), 489: strictKnownMarketplaces: z 490: .array(MarketplaceSourceSchema()) 491: .optional() 492: .describe( 493: 'Enterprise strict list of allowed marketplace sources. When set in managed settings, ' + 494: 'ONLY these exact sources can be added as marketplaces. The check happens BEFORE ' + 495: 'downloading, so blocked sources never touch the filesystem. ' + 496: 'Note: this is a policy gate only — it does NOT register marketplaces. ' + 497: 'To pre-register allowed marketplaces for users, also set extraKnownMarketplaces.', 498: ), 499: blockedMarketplaces: z 500: .array(MarketplaceSourceSchema()) 501: .optional() 502: .describe( 503: 'Enterprise blocklist of marketplace sources. When set in managed settings, ' + 504: 'these exact sources are blocked from being added as marketplaces. The check happens BEFORE ' + 505: 'downloading, so blocked sources never touch the filesystem.', 506: ), 507: forceLoginMethod: z 508: .enum(['claudeai', 'console']) 509: .optional() 510: .describe( 511: 'Force a specific login method: "claudeai" for Claude Pro/Max, "console" for Console billing', 512: ), 513: forceLoginOrgUUID: z 514: .string() 515: .optional() 516: .describe('Organization UUID to use for OAuth login'), 517: otelHeadersHelper: z 518: .string() 519: .optional() 520: .describe('Path to a script that outputs OpenTelemetry headers'), 521: outputStyle: z 522: .string() 523: .optional() 524: .describe('Controls the output style for assistant responses'), 525: language: z 526: .string() 527: .optional() 528: .describe( 529: 'Preferred language for Claude responses and voice dictation (e.g., "japanese", "spanish")', 530: ), 531: skipWebFetchPreflight: z 532: .boolean() 533: .optional() 534: .describe( 535: 'Skip the WebFetch blocklist check for enterprise environments with restrictive security policies', 536: ), 537: sandbox: SandboxSettingsSchema().optional(), 538: feedbackSurveyRate: z 539: .number() 540: .min(0) 541: .max(1) 542: .optional() 543: .describe( 544: 'Probability (0–1) that the session quality survey appears when eligible. 0.05 is a reasonable starting point.', 545: ), 546: spinnerTipsEnabled: z 547: .boolean() 548: .optional() 549: .describe('Whether to show tips in the spinner'), 550: spinnerVerbs: z 551: .object({ 552: mode: z.enum(['append', 'replace']), 553: verbs: z.array(z.string()), 554: }) 555: .optional() 556: .describe( 557: 'Customize spinner verbs. mode: "append" adds verbs to defaults, "replace" uses only your verbs.', 558: ), 559: spinnerTipsOverride: z 560: .object({ 561: excludeDefault: z.boolean().optional(), 562: tips: z.array(z.string()), 563: }) 564: .optional() 565: .describe( 566: 'Override spinner tips. tips: array of tip strings. excludeDefault: if true, only show custom tips (default: false).', 567: ), 568: syntaxHighlightingDisabled: z 569: .boolean() 570: .optional() 571: .describe('Whether to disable syntax highlighting in diffs'), 572: terminalTitleFromRename: z 573: .boolean() 574: .optional() 575: .describe( 576: 'Whether /rename updates the terminal tab title (defaults to true). Set to false to keep auto-generated topic titles.', 577: ), 578: alwaysThinkingEnabled: z 579: .boolean() 580: .optional() 581: .describe( 582: 'When false, thinking is disabled. When absent or true, thinking is ' + 583: 'enabled automatically for supported models.', 584: ), 585: effortLevel: z 586: .enum( 587: process.env.USER_TYPE === 'ant' 588: ? ['low', 'medium', 'high', 'max'] 589: : ['low', 'medium', 'high'], 590: ) 591: .optional() 592: .catch(undefined) 593: .describe('Persisted effort level for supported models.'), 594: advisorModel: z 595: .string() 596: .optional() 597: .describe('Advisor model for the server-side advisor tool.'), 598: fastMode: z 599: .boolean() 600: .optional() 601: .describe( 602: 'When true, fast mode is enabled. When absent or false, fast mode is off.', 603: ), 604: fastModePerSessionOptIn: z 605: .boolean() 606: .optional() 607: .describe( 608: 'When true, fast mode does not persist across sessions. Each session starts with fast mode off.', 609: ), 610: promptSuggestionEnabled: z 611: .boolean() 612: .optional() 613: .describe( 614: 'When false, prompt suggestions are disabled. When absent or true, ' + 615: 'prompt suggestions are enabled.', 616: ), 617: showClearContextOnPlanAccept: z 618: .boolean() 619: .optional() 620: .describe( 621: 'When true, the plan-approval dialog offers a "clear context" option. Defaults to false.', 622: ), 623: agent: z 624: .string() 625: .optional() 626: .describe( 627: 'Name of an agent (built-in or custom) to use for the main thread. ' + 628: "Applies the agent's system prompt, tool restrictions, and model.", 629: ), 630: companyAnnouncements: z 631: .array(z.string()) 632: .optional() 633: .describe( 634: 'Company announcements to display at startup (one will be randomly selected if multiple are provided)', 635: ), 636: pluginConfigs: z 637: .record( 638: z.string(), 639: z.object({ 640: mcpServers: z 641: .record( 642: z.string(), 643: z.record( 644: z.string(), 645: z.union([ 646: z.string(), 647: z.number(), 648: z.boolean(), 649: z.array(z.string()), 650: ]), 651: ), 652: ) 653: .optional() 654: .describe( 655: 'User configuration values for MCP servers keyed by server name', 656: ), 657: options: z 658: .record( 659: z.string(), 660: z.union([ 661: z.string(), 662: z.number(), 663: z.boolean(), 664: z.array(z.string()), 665: ]), 666: ) 667: .optional() 668: .describe( 669: 'Non-sensitive option values from plugin manifest userConfig, keyed by option name. Sensitive values go to secure storage instead.', 670: ), 671: }), 672: ) 673: .optional() 674: .describe( 675: 'Per-plugin configuration including MCP server user configs, keyed by plugin ID (plugin@marketplace format)', 676: ), 677: remote: z 678: .object({ 679: defaultEnvironmentId: z 680: .string() 681: .optional() 682: .describe('Default environment ID to use for remote sessions'), 683: }) 684: .optional() 685: .describe('Remote session configuration'), 686: autoUpdatesChannel: z 687: .enum(['latest', 'stable']) 688: .optional() 689: .describe('Release channel for auto-updates (latest or stable)'), 690: ...(feature('LODESTONE') 691: ? { 692: disableDeepLinkRegistration: z 693: .enum(['disable']) 694: .optional() 695: .describe( 696: 'Prevent claude-cli:// protocol handler registration with the OS', 697: ), 698: } 699: : {}), 700: minimumVersion: z 701: .string() 702: .optional() 703: .describe( 704: 'Minimum version to stay on - prevents downgrades when switching to stable channel', 705: ), 706: plansDirectory: z 707: .string() 708: .optional() 709: .describe( 710: 'Custom directory for plan files, relative to project root. ' + 711: 'If not set, defaults to ~/.claude/plans/', 712: ), 713: ...(process.env.USER_TYPE === 'ant' 714: ? { 715: classifierPermissionsEnabled: z 716: .boolean() 717: .optional() 718: .describe( 719: 'Enable AI-based classification for Bash(prompt:...) permission rules', 720: ), 721: } 722: : {}), 723: ...(feature('PROACTIVE') || feature('KAIROS') 724: ? { 725: minSleepDurationMs: z 726: .number() 727: .nonnegative() 728: .int() 729: .optional() 730: .describe( 731: 'Minimum duration in milliseconds that the Sleep tool must sleep for. ' + 732: 'Useful for throttling proactive tick frequency.', 733: ), 734: maxSleepDurationMs: z 735: .number() 736: .int() 737: .min(-1) 738: .optional() 739: .describe( 740: 'Maximum duration in milliseconds that the Sleep tool can sleep for. ' + 741: 'Set to -1 for indefinite sleep (waits for user input). ' + 742: 'Useful for limiting idle time in remote/managed environments.', 743: ), 744: } 745: : {}), 746: ...(feature('VOICE_MODE') 747: ? { 748: voiceEnabled: z 749: .boolean() 750: .optional() 751: .describe('Enable voice mode (hold-to-talk dictation)'), 752: } 753: : {}), 754: ...(feature('KAIROS') 755: ? { 756: assistant: z 757: .boolean() 758: .optional() 759: .describe( 760: 'Start Claude in assistant mode (custom system prompt, brief view, scheduled check-in skills)', 761: ), 762: assistantName: z 763: .string() 764: .optional() 765: .describe( 766: 'Display name for the assistant, shown in the claude.ai session list', 767: ), 768: } 769: : {}), 770: channelsEnabled: z 771: .boolean() 772: .optional() 773: .describe( 774: 'Teams/Enterprise opt-in for channel notifications (MCP servers with the ' + 775: 'claude/channel capability pushing inbound messages). Default off. ' + 776: 'Set true to allow; users then select servers via --channels.', 777: ), 778: allowedChannelPlugins: z 779: .array( 780: z.object({ 781: marketplace: z.string(), 782: plugin: z.string(), 783: }), 784: ) 785: .optional() 786: .describe( 787: 'Teams/Enterprise allowlist of channel plugins. When set, ' + 788: 'replaces the default Anthropic allowlist — admins decide which ' + 789: 'plugins may push inbound messages. Undefined falls back to the default. ' + 790: 'Requires channelsEnabled: true.', 791: ), 792: ...(feature('KAIROS') || feature('KAIROS_BRIEF') 793: ? { 794: defaultView: z 795: .enum(['chat', 'transcript']) 796: .optional() 797: .describe( 798: 'Default transcript view: chat (SendUserMessage checkpoints only) or transcript (full)', 799: ), 800: } 801: : {}), 802: prefersReducedMotion: z 803: .boolean() 804: .optional() 805: .describe( 806: 'Reduce or disable animations for accessibility (spinner shimmer, flash effects, etc.)', 807: ), 808: autoMemoryEnabled: z 809: .boolean() 810: .optional() 811: .describe( 812: 'Enable auto-memory for this project. When false, Claude will not read from or write to the auto-memory directory.', 813: ), 814: autoMemoryDirectory: z 815: .string() 816: .optional() 817: .describe( 818: 'Custom directory path for auto-memory storage. Supports ~/ prefix for home directory expansion. Ignored if set in projectSettings (checked-in .claude/settings.json) for security. When unset, defaults to ~/.claude/projects/<sanitized-cwd>/memory/.', 819: ), 820: autoDreamEnabled: z 821: .boolean() 822: .optional() 823: .describe( 824: 'Enable background memory consolidation (auto-dream). When set, overrides the server-side default.', 825: ), 826: showThinkingSummaries: z 827: .boolean() 828: .optional() 829: .describe( 830: 'Show thinking summaries in the transcript view (ctrl+o). Default: false.', 831: ), 832: skipDangerousModePermissionPrompt: z 833: .boolean() 834: .optional() 835: .describe( 836: 'Whether the user has accepted the bypass permissions mode dialog', 837: ), 838: ...(feature('TRANSCRIPT_CLASSIFIER') 839: ? { 840: skipAutoPermissionPrompt: z 841: .boolean() 842: .optional() 843: .describe( 844: 'Whether the user has accepted the auto mode opt-in dialog', 845: ), 846: useAutoModeDuringPlan: z 847: .boolean() 848: .optional() 849: .describe( 850: 'Whether plan mode uses auto mode semantics when auto mode is available (default: true)', 851: ), 852: autoMode: z 853: .object({ 854: allow: z 855: .array(z.string()) 856: .optional() 857: .describe('Rules for the auto mode classifier allow section'), 858: soft_deny: z 859: .array(z.string()) 860: .optional() 861: .describe('Rules for the auto mode classifier deny section'), 862: ...(process.env.USER_TYPE === 'ant' 863: ? { 864: deny: z.array(z.string()).optional(), 865: } 866: : {}), 867: environment: z 868: .array(z.string()) 869: .optional() 870: .describe( 871: 'Entries for the auto mode classifier environment section', 872: ), 873: }) 874: .optional() 875: .describe('Auto mode classifier prompt customization'), 876: } 877: : {}), 878: disableAutoMode: z 879: .enum(['disable']) 880: .optional() 881: .describe('Disable auto mode'), 882: sshConfigs: z 883: .array( 884: z.object({ 885: id: z 886: .string() 887: .describe( 888: 'Unique identifier for this SSH config. Used to match configs across settings sources.', 889: ), 890: name: z.string().describe('Display name for the SSH connection'), 891: sshHost: z 892: .string() 893: .describe( 894: 'SSH host in format "user@hostname" or "hostname", or a host alias from ~/.ssh/config', 895: ), 896: sshPort: z 897: .number() 898: .int() 899: .optional() 900: .describe('SSH port (default: 22)'), 901: sshIdentityFile: z 902: .string() 903: .optional() 904: .describe('Path to SSH identity file (private key)'), 905: startDirectory: z 906: .string() 907: .optional() 908: .describe( 909: 'Default working directory on the remote host. ' + 910: 'Supports tilde expansion (e.g. ~/projects). ' + 911: 'If not specified, defaults to the remote user home directory. ' + 912: 'Can be overridden by the [dir] positional argument in `claude ssh <config> [dir]`.', 913: ), 914: }), 915: ) 916: .optional() 917: .describe( 918: 'SSH connection configurations for remote environments. ' + 919: 'Typically set in managed settings by enterprise administrators ' + 920: 'to pre-configure SSH connections for team members.', 921: ), 922: claudeMdExcludes: z 923: .array(z.string()) 924: .optional() 925: .describe( 926: 'Glob patterns or absolute paths of CLAUDE.md files to exclude from loading. ' + 927: 'Patterns are matched against absolute file paths using picomatch. ' + 928: 'Only applies to User, Project, and Local memory types (Managed/policy files cannot be excluded). ' + 929: 'Examples: "/home/user/monorepo/CLAUDE.md", "**/code/CLAUDE.md", "**/some-dir/.claude/rules/**"', 930: ), 931: pluginTrustMessage: z 932: .string() 933: .optional() 934: .describe( 935: 'Custom message to append to the plugin trust warning shown before installation. ' + 936: 'Only read from policy settings (managed-settings.json / MDM). ' + 937: 'Useful for enterprise administrators to add organization-specific context ' + 938: '(e.g., "All plugins from our internal marketplace are vetted and approved.").', 939: ), 940: }) 941: .passthrough(), 942: ) 943: export type PluginHookMatcher = { 944: matcher?: string 945: hooks: HookCommand[] 946: pluginRoot: string 947: pluginName: string 948: pluginId: string 949: } 950: export type SkillHookMatcher = { 951: matcher?: string 952: hooks: HookCommand[] 953: skillRoot: string 954: skillName: string 955: } 956: export type AllowedMcpServerEntry = z.infer< 957: ReturnType<typeof AllowedMcpServerEntrySchema> 958: > 959: export type DeniedMcpServerEntry = z.infer< 960: ReturnType<typeof DeniedMcpServerEntrySchema> 961: > 962: export type SettingsJson = z.infer<ReturnType<typeof SettingsSchema>> 963: export function isMcpServerNameEntry( 964: entry: AllowedMcpServerEntry | DeniedMcpServerEntry, 965: ): entry is { serverName: string } { 966: return 'serverName' in entry && entry.serverName !== undefined 967: } 968: export function isMcpServerCommandEntry( 969: entry: AllowedMcpServerEntry | DeniedMcpServerEntry, 970: ): entry is { serverCommand: string[] } { 971: return 'serverCommand' in entry && entry.serverCommand !== undefined 972: } 973: export function isMcpServerUrlEntry( 974: entry: AllowedMcpServerEntry | DeniedMcpServerEntry, 975: ): entry is { serverUrl: string } { 976: return 'serverUrl' in entry && entry.serverUrl !== undefined 977: } 978: export type UserConfigValues = Record< 979: string, 980: string | number | boolean | string[] 981: > 982: export type PluginConfig = { 983: mcpServers?: { 984: [serverName: string]: UserConfigValues 985: } 986: }

File: src/utils/settings/validateEditTool.ts

typescript 1: import type { ValidationResult } from 'src/Tool.js' 2: import { isClaudeSettingsPath } from '../permissions/filesystem.js' 3: import { validateSettingsFileContent } from './validation.js' 4: export function validateInputForSettingsFileEdit( 5: filePath: string, 6: originalContent: string, 7: getUpdatedContent: () => string, 8: ): Extract<ValidationResult, { result: false }> | null { 9: if (!isClaudeSettingsPath(filePath)) { 10: return null 11: } 12: const beforeValidation = validateSettingsFileContent(originalContent) 13: if (!beforeValidation.isValid) { 14: return null 15: } 16: const updatedContent = getUpdatedContent() 17: const afterValidation = validateSettingsFileContent(updatedContent) 18: if (!afterValidation.isValid) { 19: return { 20: result: false, 21: message: `Claude Code settings.json validation failed after edit:\n${afterValidation.error}\n\nFull schema:\n${afterValidation.fullSchema}\nIMPORTANT: Do not update the env unless explicitly instructed to do so.`, 22: errorCode: 10, 23: } 24: } 25: return null 26: }

File: src/utils/settings/validation.ts

typescript 1: import type { ConfigScope } from 'src/services/mcp/types.js' 2: import type { ZodError, ZodIssue } from 'zod/v4' 3: import { jsonParse } from '../slowOperations.js' 4: import { plural } from '../stringUtils.js' 5: import { validatePermissionRule } from './permissionValidation.js' 6: import { generateSettingsJSONSchema } from './schemaOutput.js' 7: import type { SettingsJson } from './types.js' 8: import { SettingsSchema } from './types.js' 9: import { getValidationTip } from './validationTips.js' 10: function isInvalidTypeIssue(issue: ZodIssue): issue is ZodIssue & { 11: code: 'invalid_type' 12: expected: string 13: input: unknown 14: } { 15: return issue.code === 'invalid_type' 16: } 17: function isInvalidValueIssue(issue: ZodIssue): issue is ZodIssue & { 18: code: 'invalid_value' 19: values: unknown[] 20: input: unknown 21: } { 22: return issue.code === 'invalid_value' 23: } 24: function isUnrecognizedKeysIssue( 25: issue: ZodIssue, 26: ): issue is ZodIssue & { code: 'unrecognized_keys'; keys: string[] } { 27: return issue.code === 'unrecognized_keys' 28: } 29: function isTooSmallIssue(issue: ZodIssue): issue is ZodIssue & { 30: code: 'too_small' 31: minimum: number | bigint 32: origin: string 33: } { 34: return issue.code === 'too_small' 35: } 36: export type FieldPath = string 37: export type ValidationError = { 38: file?: string 39: path: FieldPath 40: message: string 41: expected?: string 42: invalidValue?: unknown 43: suggestion?: string 44: docLink?: string 45: mcpErrorMetadata?: { 46: scope: ConfigScope 47: serverName?: string 48: severity?: 'fatal' | 'warning' 49: } 50: } 51: export type SettingsWithErrors = { 52: settings: SettingsJson 53: errors: ValidationError[] 54: } 55: function getReceivedType(value: unknown): string { 56: if (value === null) return 'null' 57: if (value === undefined) return 'undefined' 58: if (Array.isArray(value)) return 'array' 59: return typeof value 60: } 61: function extractReceivedFromMessage(msg: string): string | undefined { 62: const match = msg.match(/received (\w+)/) 63: return match ? match[1] : undefined 64: } 65: export function formatZodError( 66: error: ZodError, 67: filePath: string, 68: ): ValidationError[] { 69: return error.issues.map((issue): ValidationError => { 70: const path = issue.path.map(String).join('.') 71: let message = issue.message 72: let expected: string | undefined 73: let enumValues: string[] | undefined 74: let expectedValue: string | undefined 75: let receivedValue: unknown 76: let invalidValue: unknown 77: if (isInvalidValueIssue(issue)) { 78: enumValues = issue.values.map(v => String(v)) 79: expectedValue = enumValues.join(' | ') 80: receivedValue = undefined 81: invalidValue = undefined 82: } else if (isInvalidTypeIssue(issue)) { 83: expectedValue = issue.expected 84: const receivedType = extractReceivedFromMessage(issue.message) 85: receivedValue = receivedType ?? getReceivedType(issue.input) 86: invalidValue = receivedType ?? getReceivedType(issue.input) 87: } else if (isTooSmallIssue(issue)) { 88: expectedValue = String(issue.minimum) 89: } else if (issue.code === 'custom' && 'params' in issue) { 90: const params = issue.params as { received?: unknown } 91: receivedValue = params.received 92: invalidValue = receivedValue 93: } 94: const tip = getValidationTip({ 95: path, 96: code: issue.code, 97: expected: expectedValue, 98: received: receivedValue, 99: enumValues, 100: message: issue.message, 101: value: receivedValue, 102: }) 103: if (isInvalidValueIssue(issue)) { 104: expected = enumValues?.map(v => `"${v}"`).join(', ') 105: message = `Invalid value. Expected one of: ${expected}` 106: } else if (isInvalidTypeIssue(issue)) { 107: const receivedType = 108: extractReceivedFromMessage(issue.message) ?? 109: getReceivedType(issue.input) 110: if ( 111: issue.expected === 'object' && 112: receivedType === 'null' && 113: path === '' 114: ) { 115: message = 'Invalid or malformed JSON' 116: } else { 117: message = `Expected ${issue.expected}, but received ${receivedType}` 118: } 119: } else if (isUnrecognizedKeysIssue(issue)) { 120: const keys = issue.keys.join(', ') 121: message = `Unrecognized ${plural(issue.keys.length, 'field')}: ${keys}` 122: } else if (isTooSmallIssue(issue)) { 123: message = `Number must be greater than or equal to ${issue.minimum}` 124: expected = String(issue.minimum) 125: } 126: return { 127: file: filePath, 128: path, 129: message, 130: expected, 131: invalidValue, 132: suggestion: tip?.suggestion, 133: docLink: tip?.docLink, 134: } 135: }) 136: } 137: export function validateSettingsFileContent(content: string): 138: | { 139: isValid: true 140: } 141: | { 142: isValid: false 143: error: string 144: fullSchema: string 145: } { 146: try { 147: const jsonData = jsonParse(content) 148: const result = SettingsSchema().strict().safeParse(jsonData) 149: if (result.success) { 150: return { isValid: true } 151: } 152: const errors = formatZodError(result.error, 'settings') 153: const errorMessage = 154: 'Settings validation failed:\n' + 155: errors.map(err => `- ${err.path}: ${err.message}`).join('\n') 156: return { 157: isValid: false, 158: error: errorMessage, 159: fullSchema: generateSettingsJSONSchema(), 160: } 161: } catch (parseError) { 162: return { 163: isValid: false, 164: error: `Invalid JSON: ${parseError instanceof Error ? parseError.message : 'Unknown parsing error'}`, 165: fullSchema: generateSettingsJSONSchema(), 166: } 167: } 168: } 169: export function filterInvalidPermissionRules( 170: data: unknown, 171: filePath: string, 172: ): ValidationError[] { 173: if (!data || typeof data !== 'object') return [] 174: const obj = data as Record<string, unknown> 175: if (!obj.permissions || typeof obj.permissions !== 'object') return [] 176: const perms = obj.permissions as Record<string, unknown> 177: const warnings: ValidationError[] = [] 178: for (const key of ['allow', 'deny', 'ask']) { 179: const rules = perms[key] 180: if (!Array.isArray(rules)) continue 181: perms[key] = rules.filter(rule => { 182: if (typeof rule !== 'string') { 183: warnings.push({ 184: file: filePath, 185: path: `permissions.${key}`, 186: message: `Non-string value in ${key} array was removed`, 187: invalidValue: rule, 188: }) 189: return false 190: } 191: const result = validatePermissionRule(rule) 192: if (!result.valid) { 193: let message = `Invalid permission rule "${rule}" was skipped` 194: if (result.error) message += `: ${result.error}` 195: if (result.suggestion) message += `. ${result.suggestion}` 196: warnings.push({ 197: file: filePath, 198: path: `permissions.${key}`, 199: message, 200: invalidValue: rule, 201: }) 202: return false 203: } 204: return true 205: }) 206: } 207: return warnings 208: }

File: src/utils/settings/validationTips.ts

typescript 1: import type { ZodIssueCode } from 'zod/v4' 2: type ZodIssueCodeType = (typeof ZodIssueCode)[keyof typeof ZodIssueCode] 3: export type ValidationTip = { 4: suggestion?: string 5: docLink?: string 6: } 7: export type TipContext = { 8: path: string 9: code: ZodIssueCodeType | string 10: expected?: string 11: received?: unknown 12: enumValues?: string[] 13: message?: string 14: value?: unknown 15: } 16: type TipMatcher = { 17: matches: (context: TipContext) => boolean 18: tip: ValidationTip 19: } 20: const DOCUMENTATION_BASE = 'https://code.claude.com/docs/en' 21: const TIP_MATCHERS: TipMatcher[] = [ 22: { 23: matches: (ctx): boolean => 24: ctx.path === 'permissions.defaultMode' && ctx.code === 'invalid_value', 25: tip: { 26: suggestion: 27: 'Valid modes: "acceptEdits" (ask before file changes), "plan" (analysis only), "bypassPermissions" (auto-accept all), or "default" (standard behavior)', 28: docLink: `${DOCUMENTATION_BASE}/iam#permission-modes`, 29: }, 30: }, 31: { 32: matches: (ctx): boolean => 33: ctx.path === 'apiKeyHelper' && ctx.code === 'invalid_type', 34: tip: { 35: suggestion: 36: 'Provide a shell command that outputs your API key to stdout. The script should output only the API key. Example: "/bin/generate_temp_api_key.sh"', 37: }, 38: }, 39: { 40: matches: (ctx): boolean => 41: ctx.path === 'cleanupPeriodDays' && 42: ctx.code === 'too_small' && 43: ctx.expected === '0', 44: tip: { 45: suggestion: 46: 'Must be 0 or greater. Set a positive number for days to retain transcripts (default is 30). Setting 0 disables session persistence entirely: no transcripts are written and existing transcripts are deleted at startup.', 47: }, 48: }, 49: { 50: matches: (ctx): boolean => 51: ctx.path.startsWith('env.') && ctx.code === 'invalid_type', 52: tip: { 53: suggestion: 54: 'Environment variables must be strings. Wrap numbers and booleans in quotes. Example: "DEBUG": "true", "PORT": "3000"', 55: docLink: `${DOCUMENTATION_BASE}/settings#environment-variables`, 56: }, 57: }, 58: { 59: matches: (ctx): boolean => 60: (ctx.path === 'permissions.allow' || ctx.path === 'permissions.deny') && 61: ctx.code === 'invalid_type' && 62: ctx.expected === 'array', 63: tip: { 64: suggestion: 65: 'Permission rules must be in an array. Format: ["Tool(specifier)"]. Examples: ["Bash(npm run build)", "Edit(docs/**)", "Read(~/.zshrc)"]. Use * for wildcards.', 66: }, 67: }, 68: { 69: matches: (ctx): boolean => 70: ctx.path.includes('hooks') && ctx.code === 'invalid_type', 71: tip: { 72: suggestion: 73: 'Hooks use a matcher + hooks array. The matcher is a string: a tool name ("Bash"), pipe-separated list ("Edit|Write"), or empty to match all. Example: {"PostToolUse": [{"matcher": "Edit|Write", "hooks": [{"type": "command", "command": "echo Done"}]}]}', 74: }, 75: }, 76: { 77: matches: (ctx): boolean => 78: ctx.code === 'invalid_type' && ctx.expected === 'boolean', 79: tip: { 80: suggestion: 81: 'Use true or false without quotes. Example: "includeCoAuthoredBy": true', 82: }, 83: }, 84: { 85: matches: (ctx): boolean => ctx.code === 'unrecognized_keys', 86: tip: { 87: suggestion: 88: 'Check for typos or refer to the documentation for valid fields', 89: docLink: `${DOCUMENTATION_BASE}/settings`, 90: }, 91: }, 92: { 93: matches: (ctx): boolean => 94: ctx.code === 'invalid_value' && ctx.enumValues !== undefined, 95: tip: { 96: suggestion: undefined, 97: }, 98: }, 99: { 100: matches: (ctx): boolean => 101: ctx.code === 'invalid_type' && 102: ctx.expected === 'object' && 103: ctx.received === null && 104: ctx.path === '', 105: tip: { 106: suggestion: 107: 'Check for missing commas, unmatched brackets, or trailing commas. Use a JSON validator to identify the exact syntax error.', 108: }, 109: }, 110: { 111: matches: (ctx): boolean => 112: ctx.path === 'permissions.additionalDirectories' && 113: ctx.code === 'invalid_type', 114: tip: { 115: suggestion: 116: 'Must be an array of directory paths. Example: ["~/projects", "/tmp/workspace"]. You can also use --add-dir flag or /add-dir command', 117: docLink: `${DOCUMENTATION_BASE}/iam#working-directories`, 118: }, 119: }, 120: ] 121: const PATH_DOC_LINKS: Record<string, string> = { 122: permissions: `${DOCUMENTATION_BASE}/iam#configuring-permissions`, 123: env: `${DOCUMENTATION_BASE}/settings#environment-variables`, 124: hooks: `${DOCUMENTATION_BASE}/hooks`, 125: } 126: export function getValidationTip(context: TipContext): ValidationTip | null { 127: const matcher = TIP_MATCHERS.find(m => m.matches(context)) 128: if (!matcher) return null 129: const tip: ValidationTip = { ...matcher.tip } 130: if ( 131: context.code === 'invalid_value' && 132: context.enumValues && 133: !tip.suggestion 134: ) { 135: tip.suggestion = `Valid values: ${context.enumValues.map(v => `"${v}"`).join(', ')}` 136: } 137: if (!tip.docLink && context.path) { 138: const pathPrefix = context.path.split('.')[0] 139: if (pathPrefix) { 140: tip.docLink = PATH_DOC_LINKS[pathPrefix] 141: } 142: } 143: return tip 144: }

File: src/utils/shell/bashProvider.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { access } from 'fs/promises' 3: import { tmpdir as osTmpdir } from 'os' 4: import { join as nativeJoin } from 'path' 5: import { join as posixJoin } from 'path/posix' 6: import { rearrangePipeCommand } from '../bash/bashPipeCommand.js' 7: import { createAndSaveSnapshot } from '../bash/ShellSnapshot.js' 8: import { formatShellPrefixCommand } from '../bash/shellPrefix.js' 9: import { quote } from '../bash/shellQuote.js' 10: import { 11: quoteShellCommand, 12: rewriteWindowsNullRedirect, 13: shouldAddStdinRedirect, 14: } from '../bash/shellQuoting.js' 15: import { logForDebugging } from '../debug.js' 16: import { getPlatform } from '../platform.js' 17: import { getSessionEnvironmentScript } from '../sessionEnvironment.js' 18: import { getSessionEnvVars } from '../sessionEnvVars.js' 19: import { 20: ensureSocketInitialized, 21: getClaudeTmuxEnv, 22: hasTmuxToolBeenUsed, 23: } from '../tmuxSocket.js' 24: import { windowsPathToPosixPath } from '../windowsPaths.js' 25: import type { ShellProvider } from './shellProvider.js' 26: function getDisableExtglobCommand(shellPath: string): string | null { 27: if (process.env.CLAUDE_CODE_SHELL_PREFIX) { 28: return '{ shopt -u extglob || setopt NO_EXTENDED_GLOB; } >/dev/null 2>&1 || true' 29: } 30: if (shellPath.includes('bash')) { 31: return 'shopt -u extglob 2>/dev/null || true' 32: } else if (shellPath.includes('zsh')) { 33: return 'setopt NO_EXTENDED_GLOB 2>/dev/null || true' 34: } 35: return null 36: } 37: export async function createBashShellProvider( 38: shellPath: string, 39: options?: { skipSnapshot?: boolean }, 40: ): Promise<ShellProvider> { 41: let currentSandboxTmpDir: string | undefined 42: const snapshotPromise: Promise<string | undefined> = options?.skipSnapshot 43: ? Promise.resolve(undefined) 44: : createAndSaveSnapshot(shellPath).catch(error => { 45: logForDebugging(`Failed to create shell snapshot: ${error}`) 46: return undefined 47: }) 48: let lastSnapshotFilePath: string | undefined 49: return { 50: type: 'bash', 51: shellPath, 52: detached: true, 53: async buildExecCommand( 54: command: string, 55: opts: { 56: id: number | string 57: sandboxTmpDir?: string 58: useSandbox: boolean 59: }, 60: ): Promise<{ commandString: string; cwdFilePath: string }> { 61: let snapshotFilePath = await snapshotPromise 62: if (snapshotFilePath) { 63: try { 64: await access(snapshotFilePath) 65: } catch { 66: logForDebugging( 67: `Snapshot file missing, falling back to login shell: ${snapshotFilePath}`, 68: ) 69: snapshotFilePath = undefined 70: } 71: } 72: lastSnapshotFilePath = snapshotFilePath 73: currentSandboxTmpDir = opts.sandboxTmpDir 74: const tmpdir = osTmpdir() 75: const isWindows = getPlatform() === 'windows' 76: const shellTmpdir = isWindows ? windowsPathToPosixPath(tmpdir) : tmpdir 77: const shellCwdFilePath = opts.useSandbox 78: ? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`) 79: : posixJoin(shellTmpdir, `claude-${opts.id}-cwd`) 80: const cwdFilePath = opts.useSandbox 81: ? posixJoin(opts.sandboxTmpDir!, `cwd-${opts.id}`) 82: : nativeJoin(tmpdir, `claude-${opts.id}-cwd`) 83: const normalizedCommand = rewriteWindowsNullRedirect(command) 84: const addStdinRedirect = shouldAddStdinRedirect(normalizedCommand) 85: let quotedCommand = quoteShellCommand(normalizedCommand, addStdinRedirect) 86: if ( 87: feature('COMMIT_ATTRIBUTION') && 88: (command.includes('<<') || command.includes('\n')) 89: ) { 90: logForDebugging( 91: `Shell: Command before quoting (first 500 chars):\n${command.slice(0, 500)}`, 92: ) 93: logForDebugging( 94: `Shell: Quoted command (first 500 chars):\n${quotedCommand.slice(0, 500)}`, 95: ) 96: } 97: if (normalizedCommand.includes('|') && addStdinRedirect) { 98: quotedCommand = rearrangePipeCommand(normalizedCommand) 99: } 100: const commandParts: string[] = [] 101: if (snapshotFilePath) { 102: const finalPath = 103: getPlatform() === 'windows' 104: ? windowsPathToPosixPath(snapshotFilePath) 105: : snapshotFilePath 106: commandParts.push(`source ${quote([finalPath])} 2>/dev/null || true`) 107: } 108: const sessionEnvScript = await getSessionEnvironmentScript() 109: if (sessionEnvScript) { 110: commandParts.push(sessionEnvScript) 111: } 112: const disableExtglobCmd = getDisableExtglobCommand(shellPath) 113: if (disableExtglobCmd) { 114: commandParts.push(disableExtglobCmd) 115: } 116: commandParts.push(`eval ${quotedCommand}`) 117: commandParts.push(`pwd -P >| ${quote([shellCwdFilePath])}`) 118: let commandString = commandParts.join(' && ') 119: if (process.env.CLAUDE_CODE_SHELL_PREFIX) { 120: commandString = formatShellPrefixCommand( 121: process.env.CLAUDE_CODE_SHELL_PREFIX, 122: commandString, 123: ) 124: } 125: return { commandString, cwdFilePath } 126: }, 127: getSpawnArgs(commandString: string): string[] { 128: const skipLoginShell = lastSnapshotFilePath !== undefined 129: if (skipLoginShell) { 130: logForDebugging('Spawning shell without login (-l flag skipped)') 131: } 132: return ['-c', ...(skipLoginShell ? [] : ['-l']), commandString] 133: }, 134: async getEnvironmentOverrides( 135: command: string, 136: ): Promise<Record<string, string>> { 137: const commandUsesTmux = command.includes('tmux') 138: if ( 139: process.env.USER_TYPE === 'ant' && 140: (hasTmuxToolBeenUsed() || commandUsesTmux) 141: ) { 142: await ensureSocketInitialized() 143: } 144: const claudeTmuxEnv = getClaudeTmuxEnv() 145: const env: Record<string, string> = {} 146: if (claudeTmuxEnv) { 147: env.TMUX = claudeTmuxEnv 148: } 149: if (currentSandboxTmpDir) { 150: let posixTmpDir = currentSandboxTmpDir 151: if (getPlatform() === 'windows') { 152: posixTmpDir = windowsPathToPosixPath(posixTmpDir) 153: } 154: env.TMPDIR = posixTmpDir 155: env.CLAUDE_CODE_TMPDIR = posixTmpDir 156: env.TMPPREFIX = posixJoin(posixTmpDir, 'zsh') 157: } 158: for (const [key, value] of getSessionEnvVars()) { 159: env[key] = value 160: } 161: return env 162: }, 163: } 164: }

File: src/utils/shell/outputLimits.ts

typescript 1: import { validateBoundedIntEnvVar } from '../envValidation.js' 2: export const BASH_MAX_OUTPUT_UPPER_LIMIT = 150_000 3: export const BASH_MAX_OUTPUT_DEFAULT = 30_000 4: export function getMaxOutputLength(): number { 5: const result = validateBoundedIntEnvVar( 6: 'BASH_MAX_OUTPUT_LENGTH', 7: process.env.BASH_MAX_OUTPUT_LENGTH, 8: BASH_MAX_OUTPUT_DEFAULT, 9: BASH_MAX_OUTPUT_UPPER_LIMIT, 10: ) 11: return result.effective 12: }

File: src/utils/shell/powershellDetection.ts

typescript 1: import { realpath, stat } from 'fs/promises' 2: import { getPlatform } from '../platform.js' 3: import { which } from '../which.js' 4: async function probePath(p: string): Promise<string | null> { 5: try { 6: return (await stat(p)).isFile() ? p : null 7: } catch { 8: return null 9: } 10: } 11: export async function findPowerShell(): Promise<string | null> { 12: const pwshPath = await which('pwsh') 13: if (pwshPath) { 14: if (getPlatform() === 'linux') { 15: const resolved = await realpath(pwshPath).catch(() => pwshPath) 16: if (pwshPath.startsWith('/snap/') || resolved.startsWith('/snap/')) { 17: const direct = 18: (await probePath('/opt/microsoft/powershell/7/pwsh')) ?? 19: (await probePath('/usr/bin/pwsh')) 20: if (direct) { 21: const directResolved = await realpath(direct).catch(() => direct) 22: if ( 23: !direct.startsWith('/snap/') && 24: !directResolved.startsWith('/snap/') 25: ) { 26: return direct 27: } 28: } 29: } 30: } 31: return pwshPath 32: } 33: const powershellPath = await which('powershell') 34: if (powershellPath) { 35: return powershellPath 36: } 37: return null 38: } 39: let cachedPowerShellPath: Promise<string | null> | null = null 40: export function getCachedPowerShellPath(): Promise<string | null> { 41: if (!cachedPowerShellPath) { 42: cachedPowerShellPath = findPowerShell() 43: } 44: return cachedPowerShellPath 45: } 46: export type PowerShellEdition = 'core' | 'desktop' 47: export async function getPowerShellEdition(): Promise<PowerShellEdition | null> { 48: const p = await getCachedPowerShellPath() 49: if (!p) return null 50: const base = p 51: .split(/[/\\]/) 52: .pop()! 53: .toLowerCase() 54: .replace(/\.exe$/, '') 55: return base === 'pwsh' ? 'core' : 'desktop' 56: } 57: export function resetPowerShellCache(): void { 58: cachedPowerShellPath = null 59: }

File: src/utils/shell/powershellProvider.ts

typescript 1: import { tmpdir } from 'os' 2: import { join } from 'path' 3: import { join as posixJoin } from 'path/posix' 4: import { getSessionEnvVars } from '../sessionEnvVars.js' 5: import type { ShellProvider } from './shellProvider.js' 6: export function buildPowerShellArgs(cmd: string): string[] { 7: return ['-NoProfile', '-NonInteractive', '-Command', cmd] 8: } 9: function encodePowerShellCommand(psCommand: string): string { 10: return Buffer.from(psCommand, 'utf16le').toString('base64') 11: } 12: export function createPowerShellProvider(shellPath: string): ShellProvider { 13: let currentSandboxTmpDir: string | undefined 14: return { 15: type: 'powershell' as ShellProvider['type'], 16: shellPath, 17: detached: false, 18: async buildExecCommand( 19: command: string, 20: opts: { 21: id: number | string 22: sandboxTmpDir?: string 23: useSandbox: boolean 24: }, 25: ): Promise<{ commandString: string; cwdFilePath: string }> { 26: currentSandboxTmpDir = opts.useSandbox ? opts.sandboxTmpDir : undefined 27: const cwdFilePath = 28: opts.useSandbox && opts.sandboxTmpDir 29: ? posixJoin(opts.sandboxTmpDir, `claude-pwd-ps-${opts.id}`) 30: : join(tmpdir(), `claude-pwd-ps-${opts.id}`) 31: const escapedCwdFilePath = cwdFilePath.replace(/'/g, "''") 32: // Exit-code capture: prefer $LASTEXITCODE when a native exe ran. 33: // On PS 5.1, a native command that writes to stderr while the stream 34: // is PS-redirected (e.g. `git push 2>&1`) sets $? = $false even when 35: // the exe returned exit 0 — so `if (!$?)` reports a false positive. 36: // $LASTEXITCODE is $null only when no native exe has run in the 37: // session; in that case fall back to $? for cmdlet-only pipelines. 38: // Tradeoff: `native-ok; cmdlet-fail` now returns 0 (was 1). Reverse 39: // is also true: `native-fail; cmdlet-ok` now returns the native 40: // exit code (was 0 — old logic only looked at $? which the trailing 41: // cmdlet set true). Both rarer than the git/npm/curl stderr case. 42: const cwdTracking = `\n; $_ec = if ($null -ne $LASTEXITCODE) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 }\n; (Get-Location).Path | Out-File -FilePath '${escapedCwdFilePath}' -Encoding utf8 -NoNewline\n; exit $_ec` 43: const psCommand = command + cwdTracking 44: // Sandbox wraps the returned commandString as `<binShell> -c '<cmd>'` — 45: // hardcoded `-c`, no way to inject -NoProfile -NonInteractive. So for 46: // the sandbox path, build a command that itself invokes pwsh with the 47: // full flag set. Shell.ts passes /bin/sh as the sandbox binShell, 48: // producing: bwrap ... sh -c 'pwsh -NoProfile ... -EncodedCommand ...'. 49: // The non-sandbox path returns the bare PS command; getSpawnArgs() adds 50: // the flags via buildPowerShellArgs(). 51: // 52: // -EncodedCommand (base64 UTF-16LE), not -Command: the sandbox runtime 53: // applies its OWN shellquote.quote() on top of whatever we build. Any 54: // string containing ' triggers double-quote mode which escapes ! as \! — 55: const commandString = opts.useSandbox 56: ? [ 57: `'${shellPath.replace(/'/g, `'\\''`)}'`, 58: '-NoProfile', 59: '-NonInteractive', 60: '-EncodedCommand', 61: encodePowerShellCommand(psCommand), 62: ].join(' ') 63: : psCommand 64: return { commandString, cwdFilePath } 65: }, 66: getSpawnArgs(commandString: string): string[] { 67: return buildPowerShellArgs(commandString) 68: }, 69: async getEnvironmentOverrides(): Promise<Record<string, string>> { 70: const env: Record<string, string> = {} 71: for (const [key, value] of getSessionEnvVars()) { 72: env[key] = value 73: } 74: if (currentSandboxTmpDir) { 75: env.TMPDIR = currentSandboxTmpDir 76: env.CLAUDE_CODE_TMPDIR = currentSandboxTmpDir 77: } 78: return env 79: }, 80: } 81: }

File: src/utils/shell/prefix.ts

typescript 1: import chalk from 'chalk' 2: import type { QuerySource } from '../../constants/querySource.js' 3: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 4: import { 5: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 6: logEvent, 7: } from '../../services/analytics/index.js' 8: import { queryHaiku } from '../../services/api/claude.js' 9: import { startsWithApiErrorPrefix } from '../../services/api/errors.js' 10: import { memoizeWithLRU } from '../memoize.js' 11: import { jsonStringify } from '../slowOperations.js' 12: import { asSystemPrompt } from '../systemPromptType.js' 13: const DANGEROUS_SHELL_PREFIXES = new Set([ 14: 'sh', 15: 'bash', 16: 'zsh', 17: 'fish', 18: 'csh', 19: 'tcsh', 20: 'ksh', 21: 'dash', 22: 'cmd', 23: 'cmd.exe', 24: 'powershell', 25: 'powershell.exe', 26: 'pwsh', 27: 'pwsh.exe', 28: 'bash.exe', 29: ]) 30: export type CommandPrefixResult = { 31: commandPrefix: string | null 32: } 33: export type CommandSubcommandPrefixResult = CommandPrefixResult & { 34: subcommandPrefixes: Map<string, CommandPrefixResult> 35: } 36: export type PrefixExtractorConfig = { 37: toolName: string 38: policySpec: string 39: eventName: string 40: querySource: QuerySource 41: preCheck?: (command: string) => CommandPrefixResult | null 42: } 43: export function createCommandPrefixExtractor(config: PrefixExtractorConfig) { 44: const { toolName, policySpec, eventName, querySource, preCheck } = config 45: const memoized = memoizeWithLRU( 46: ( 47: command: string, 48: abortSignal: AbortSignal, 49: isNonInteractiveSession: boolean, 50: ): Promise<CommandPrefixResult | null> => { 51: const promise = getCommandPrefixImpl( 52: command, 53: abortSignal, 54: isNonInteractiveSession, 55: toolName, 56: policySpec, 57: eventName, 58: querySource, 59: preCheck, 60: ) 61: promise.catch(() => { 62: if (memoized.cache.get(command) === promise) { 63: memoized.cache.delete(command) 64: } 65: }) 66: return promise 67: }, 68: command => command, 69: 200, 70: ) 71: return memoized 72: } 73: export function createSubcommandPrefixExtractor( 74: getPrefix: ReturnType<typeof createCommandPrefixExtractor>, 75: splitCommand: (command: string) => string[] | Promise<string[]>, 76: ) { 77: const memoized = memoizeWithLRU( 78: ( 79: command: string, 80: abortSignal: AbortSignal, 81: isNonInteractiveSession: boolean, 82: ): Promise<CommandSubcommandPrefixResult | null> => { 83: const promise = getCommandSubcommandPrefixImpl( 84: command, 85: abortSignal, 86: isNonInteractiveSession, 87: getPrefix, 88: splitCommand, 89: ) 90: promise.catch(() => { 91: if (memoized.cache.get(command) === promise) { 92: memoized.cache.delete(command) 93: } 94: }) 95: return promise 96: }, 97: command => command, 98: 200, 99: ) 100: return memoized 101: } 102: async function getCommandPrefixImpl( 103: command: string, 104: abortSignal: AbortSignal, 105: isNonInteractiveSession: boolean, 106: toolName: string, 107: policySpec: string, 108: eventName: string, 109: querySource: QuerySource, 110: preCheck?: (command: string) => CommandPrefixResult | null, 111: ): Promise<CommandPrefixResult | null> { 112: if (process.env.NODE_ENV === 'test') { 113: return null 114: } 115: if (preCheck) { 116: const preCheckResult = preCheck(command) 117: if (preCheckResult !== null) { 118: return preCheckResult 119: } 120: } 121: let preflightCheckTimeoutId: NodeJS.Timeout | undefined 122: const startTime = Date.now() 123: let result: CommandPrefixResult | null = null 124: try { 125: preflightCheckTimeoutId = setTimeout( 126: (tn, nonInteractive) => { 127: const message = `[${tn}Tool] Pre-flight check is taking longer than expected. Run with ANTHROPIC_LOG=debug to check for failed or slow API requests.` 128: if (nonInteractive) { 129: process.stderr.write(jsonStringify({ level: 'warn', message }) + '\n') 130: } else { 131: console.warn(chalk.yellow(`⚠️ ${message}`)) 132: } 133: }, 134: 10000, 135: toolName, 136: isNonInteractiveSession, 137: ) 138: const useSystemPromptPolicySpec = getFeatureValue_CACHED_MAY_BE_STALE( 139: 'tengu_cork_m4q', 140: false, 141: ) 142: const response = await queryHaiku({ 143: systemPrompt: asSystemPrompt( 144: useSystemPromptPolicySpec 145: ? [ 146: `Your task is to process ${toolName} commands that an AI coding agent wants to run.\n\n${policySpec}`, 147: ] 148: : [ 149: `Your task is to process ${toolName} commands that an AI coding agent wants to run.\n\nThis policy spec defines how to determine the prefix of a ${toolName} command:`, 150: ], 151: ), 152: userPrompt: useSystemPromptPolicySpec 153: ? `Command: ${command}` 154: : `${policySpec}\n\nCommand: ${command}`, 155: signal: abortSignal, 156: options: { 157: enablePromptCaching: useSystemPromptPolicySpec, 158: querySource, 159: agents: [], 160: isNonInteractiveSession, 161: hasAppendSystemPrompt: false, 162: mcpTools: [], 163: }, 164: }) 165: clearTimeout(preflightCheckTimeoutId) 166: const durationMs = Date.now() - startTime 167: const prefix = 168: typeof response.message.content === 'string' 169: ? response.message.content 170: : Array.isArray(response.message.content) 171: ? (response.message.content.find(_ => _.type === 'text')?.text ?? 172: 'none') 173: : 'none' 174: if (startsWithApiErrorPrefix(prefix)) { 175: logEvent(eventName, { 176: success: false, 177: error: 178: 'API error' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 179: durationMs, 180: }) 181: result = null 182: } else if (prefix === 'command_injection_detected') { 183: logEvent(eventName, { 184: success: false, 185: error: 186: 'command_injection_detected' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 187: durationMs, 188: }) 189: result = { 190: commandPrefix: null, 191: } 192: } else if ( 193: prefix === 'git' || 194: DANGEROUS_SHELL_PREFIXES.has(prefix.toLowerCase()) 195: ) { 196: logEvent(eventName, { 197: success: false, 198: error: 199: 'dangerous_shell_prefix' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 200: durationMs, 201: }) 202: result = { 203: commandPrefix: null, 204: } 205: } else if (prefix === 'none') { 206: logEvent(eventName, { 207: success: false, 208: error: 209: 'prefix "none"' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 210: durationMs, 211: }) 212: result = { 213: commandPrefix: null, 214: } 215: } else { 216: if (!command.startsWith(prefix)) { 217: logEvent(eventName, { 218: success: false, 219: error: 220: 'command did not start with prefix' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 221: durationMs, 222: }) 223: result = { 224: commandPrefix: null, 225: } 226: } else { 227: logEvent(eventName, { 228: success: true, 229: durationMs, 230: }) 231: result = { 232: commandPrefix: prefix, 233: } 234: } 235: } 236: return result 237: } catch (error) { 238: clearTimeout(preflightCheckTimeoutId) 239: throw error 240: } 241: } 242: async function getCommandSubcommandPrefixImpl( 243: command: string, 244: abortSignal: AbortSignal, 245: isNonInteractiveSession: boolean, 246: getPrefix: ReturnType<typeof createCommandPrefixExtractor>, 247: splitCommandFn: (command: string) => string[] | Promise<string[]>, 248: ): Promise<CommandSubcommandPrefixResult | null> { 249: const subcommands = await splitCommandFn(command) 250: const [fullCommandPrefix, ...subcommandPrefixesResults] = await Promise.all([ 251: getPrefix(command, abortSignal, isNonInteractiveSession), 252: ...subcommands.map(async subcommand => ({ 253: subcommand, 254: prefix: await getPrefix(subcommand, abortSignal, isNonInteractiveSession), 255: })), 256: ]) 257: if (!fullCommandPrefix) { 258: return null 259: } 260: const subcommandPrefixes = subcommandPrefixesResults.reduce( 261: (acc, { subcommand, prefix }) => { 262: if (prefix) { 263: acc.set(subcommand, prefix) 264: } 265: return acc 266: }, 267: new Map<string, CommandPrefixResult>(), 268: ) 269: return { 270: ...fullCommandPrefix, 271: subcommandPrefixes, 272: } 273: }

File: src/utils/shell/readOnlyCommandValidation.ts

typescript 1: import { getPlatform } from '../platform.js' 2: export type FlagArgType = 3: | 'none' 4: | 'number' 5: | 'string' 6: | 'char' 7: | '{}' 8: | 'EOF' 9: export type ExternalCommandConfig = { 10: safeFlags: Record<string, FlagArgType> 11: additionalCommandIsDangerousCallback?: ( 12: rawCommand: string, 13: args: string[], 14: ) => boolean 15: respectsDoubleDash?: boolean 16: } 17: const GIT_REF_SELECTION_FLAGS: Record<string, FlagArgType> = { 18: '--all': 'none', 19: '--branches': 'none', 20: '--tags': 'none', 21: '--remotes': 'none', 22: } 23: const GIT_DATE_FILTER_FLAGS: Record<string, FlagArgType> = { 24: '--since': 'string', 25: '--after': 'string', 26: '--until': 'string', 27: '--before': 'string', 28: } 29: const GIT_LOG_DISPLAY_FLAGS: Record<string, FlagArgType> = { 30: '--oneline': 'none', 31: '--graph': 'none', 32: '--decorate': 'none', 33: '--no-decorate': 'none', 34: '--date': 'string', 35: '--relative-date': 'none', 36: } 37: const GIT_COUNT_FLAGS: Record<string, FlagArgType> = { 38: '--max-count': 'number', 39: '-n': 'number', 40: } 41: const GIT_STAT_FLAGS: Record<string, FlagArgType> = { 42: '--stat': 'none', 43: '--numstat': 'none', 44: '--shortstat': 'none', 45: '--name-only': 'none', 46: '--name-status': 'none', 47: } 48: const GIT_COLOR_FLAGS: Record<string, FlagArgType> = { 49: '--color': 'none', 50: '--no-color': 'none', 51: } 52: const GIT_PATCH_FLAGS: Record<string, FlagArgType> = { 53: '--patch': 'none', 54: '-p': 'none', 55: '--no-patch': 'none', 56: '--no-ext-diff': 'none', 57: '-s': 'none', 58: } 59: const GIT_AUTHOR_FILTER_FLAGS: Record<string, FlagArgType> = { 60: '--author': 'string', 61: '--committer': 'string', 62: '--grep': 'string', 63: } 64: export const GIT_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = { 65: 'git diff': { 66: safeFlags: { 67: ...GIT_STAT_FLAGS, 68: ...GIT_COLOR_FLAGS, 69: '--dirstat': 'none', 70: '--summary': 'none', 71: '--patch-with-stat': 'none', 72: '--word-diff': 'none', 73: '--word-diff-regex': 'string', 74: '--color-words': 'none', 75: '--no-renames': 'none', 76: '--no-ext-diff': 'none', 77: '--check': 'none', 78: '--ws-error-highlight': 'string', 79: '--full-index': 'none', 80: '--binary': 'none', 81: '--abbrev': 'number', 82: '--break-rewrites': 'none', 83: '--find-renames': 'none', 84: '--find-copies': 'none', 85: '--find-copies-harder': 'none', 86: '--irreversible-delete': 'none', 87: '--diff-algorithm': 'string', 88: '--histogram': 'none', 89: '--patience': 'none', 90: '--minimal': 'none', 91: '--ignore-space-at-eol': 'none', 92: '--ignore-space-change': 'none', 93: '--ignore-all-space': 'none', 94: '--ignore-blank-lines': 'none', 95: '--inter-hunk-context': 'number', 96: '--function-context': 'none', 97: '--exit-code': 'none', 98: '--quiet': 'none', 99: '--cached': 'none', 100: '--staged': 'none', 101: '--pickaxe-regex': 'none', 102: '--pickaxe-all': 'none', 103: '--no-index': 'none', 104: '--relative': 'string', 105: '--diff-filter': 'string', 106: '-p': 'none', 107: '-u': 'none', 108: '-s': 'none', 109: '-M': 'none', 110: '-C': 'none', 111: '-B': 'none', 112: '-D': 'none', 113: '-l': 'none', 114: '-S': 'string', 115: '-G': 'string', 116: '-O': 'string', 117: '-R': 'none', 118: }, 119: }, 120: 'git log': { 121: safeFlags: { 122: ...GIT_LOG_DISPLAY_FLAGS, 123: ...GIT_REF_SELECTION_FLAGS, 124: ...GIT_DATE_FILTER_FLAGS, 125: ...GIT_COUNT_FLAGS, 126: ...GIT_STAT_FLAGS, 127: ...GIT_COLOR_FLAGS, 128: ...GIT_PATCH_FLAGS, 129: ...GIT_AUTHOR_FILTER_FLAGS, 130: '--abbrev-commit': 'none', 131: '--full-history': 'none', 132: '--dense': 'none', 133: '--sparse': 'none', 134: '--simplify-merges': 'none', 135: '--ancestry-path': 'none', 136: '--source': 'none', 137: '--first-parent': 'none', 138: '--merges': 'none', 139: '--no-merges': 'none', 140: '--reverse': 'none', 141: '--walk-reflogs': 'none', 142: '--skip': 'number', 143: '--max-age': 'number', 144: '--min-age': 'number', 145: '--no-min-parents': 'none', 146: '--no-max-parents': 'none', 147: '--follow': 'none', 148: '--no-walk': 'none', 149: '--left-right': 'none', 150: '--cherry-mark': 'none', 151: '--cherry-pick': 'none', 152: '--boundary': 'none', 153: '--topo-order': 'none', 154: '--date-order': 'none', 155: '--author-date-order': 'none', 156: '--pretty': 'string', 157: '--format': 'string', 158: '--diff-filter': 'string', 159: '-S': 'string', 160: '-G': 'string', 161: '--pickaxe-regex': 'none', 162: '--pickaxe-all': 'none', 163: }, 164: }, 165: 'git show': { 166: safeFlags: { 167: ...GIT_LOG_DISPLAY_FLAGS, 168: ...GIT_STAT_FLAGS, 169: ...GIT_COLOR_FLAGS, 170: ...GIT_PATCH_FLAGS, 171: '--abbrev-commit': 'none', 172: '--word-diff': 'none', 173: '--word-diff-regex': 'string', 174: '--color-words': 'none', 175: '--pretty': 'string', 176: '--format': 'string', 177: '--first-parent': 'none', 178: '--raw': 'none', 179: '--diff-filter': 'string', 180: '-m': 'none', 181: '--quiet': 'none', 182: }, 183: }, 184: 'git shortlog': { 185: safeFlags: { 186: ...GIT_REF_SELECTION_FLAGS, 187: ...GIT_DATE_FILTER_FLAGS, 188: '-s': 'none', 189: '--summary': 'none', 190: '-n': 'none', 191: '--numbered': 'none', 192: '-e': 'none', 193: '--email': 'none', 194: '-c': 'none', 195: '--committer': 'none', 196: '--group': 'string', 197: '--format': 'string', 198: '--no-merges': 'none', 199: '--author': 'string', 200: }, 201: }, 202: 'git reflog': { 203: safeFlags: { 204: ...GIT_LOG_DISPLAY_FLAGS, 205: ...GIT_REF_SELECTION_FLAGS, 206: ...GIT_DATE_FILTER_FLAGS, 207: ...GIT_COUNT_FLAGS, 208: ...GIT_AUTHOR_FILTER_FLAGS, 209: }, 210: additionalCommandIsDangerousCallback: ( 211: _rawCommand: string, 212: args: string[], 213: ) => { 214: const DANGEROUS_SUBCOMMANDS = new Set(['expire', 'delete', 'exists']) 215: for (const token of args) { 216: if (!token || token.startsWith('-')) continue 217: if (DANGEROUS_SUBCOMMANDS.has(token)) { 218: return true 219: } 220: return false 221: } 222: return false 223: }, 224: }, 225: 'git stash list': { 226: safeFlags: { 227: ...GIT_LOG_DISPLAY_FLAGS, 228: ...GIT_REF_SELECTION_FLAGS, 229: ...GIT_COUNT_FLAGS, 230: }, 231: }, 232: 'git ls-remote': { 233: safeFlags: { 234: '--branches': 'none', 235: '-b': 'none', 236: '--tags': 'none', 237: '-t': 'none', 238: '--heads': 'none', 239: '-h': 'none', 240: '--refs': 'none', 241: '--quiet': 'none', 242: '-q': 'none', 243: '--exit-code': 'none', 244: '--get-url': 'none', 245: '--symref': 'none', 246: '--sort': 'string', 247: }, 248: }, 249: 'git status': { 250: safeFlags: { 251: '--short': 'none', 252: '-s': 'none', 253: '--branch': 'none', 254: '-b': 'none', 255: '--porcelain': 'none', 256: '--long': 'none', 257: '--verbose': 'none', 258: '-v': 'none', 259: '--untracked-files': 'string', 260: '-u': 'string', 261: '--ignored': 'none', 262: '--ignore-submodules': 'string', 263: '--column': 'none', 264: '--no-column': 'none', 265: '--ahead-behind': 'none', 266: '--no-ahead-behind': 'none', 267: '--renames': 'none', 268: '--no-renames': 'none', 269: '--find-renames': 'string', 270: '-M': 'string', 271: }, 272: }, 273: 'git blame': { 274: safeFlags: { 275: ...GIT_COLOR_FLAGS, 276: '-L': 'string', 277: '--porcelain': 'none', 278: '-p': 'none', 279: '--line-porcelain': 'none', 280: '--incremental': 'none', 281: '--root': 'none', 282: '--show-stats': 'none', 283: '--show-name': 'none', 284: '--show-number': 'none', 285: '-n': 'none', 286: '--show-email': 'none', 287: '-e': 'none', 288: '-f': 'none', 289: '--date': 'string', 290: '-w': 'none', 291: '--ignore-rev': 'string', 292: '--ignore-revs-file': 'string', 293: '-M': 'none', 294: '-C': 'none', 295: '--score-debug': 'none', 296: '--abbrev': 'number', 297: '-s': 'none', 298: '-l': 'none', 299: '-t': 'none', 300: }, 301: }, 302: 'git ls-files': { 303: safeFlags: { 304: '--cached': 'none', 305: '-c': 'none', 306: '--deleted': 'none', 307: '-d': 'none', 308: '--modified': 'none', 309: '-m': 'none', 310: '--others': 'none', 311: '-o': 'none', 312: '--ignored': 'none', 313: '-i': 'none', 314: '--stage': 'none', 315: '-s': 'none', 316: '--killed': 'none', 317: '-k': 'none', 318: '--unmerged': 'none', 319: '-u': 'none', 320: '--directory': 'none', 321: '--no-empty-directory': 'none', 322: '--eol': 'none', 323: '--full-name': 'none', 324: '--abbrev': 'number', 325: '--debug': 'none', 326: '-z': 'none', 327: '-t': 'none', 328: '-v': 'none', 329: '-f': 'none', 330: '--exclude': 'string', 331: '-x': 'string', 332: '--exclude-from': 'string', 333: '-X': 'string', 334: '--exclude-per-directory': 'string', 335: '--exclude-standard': 'none', 336: '--error-unmatch': 'none', 337: '--recurse-submodules': 'none', 338: }, 339: }, 340: 'git config --get': { 341: safeFlags: { 342: '--local': 'none', 343: '--global': 'none', 344: '--system': 'none', 345: '--worktree': 'none', 346: '--default': 'string', 347: '--type': 'string', 348: '--bool': 'none', 349: '--int': 'none', 350: '--bool-or-int': 'none', 351: '--path': 'none', 352: '--expiry-date': 'none', 353: '-z': 'none', 354: '--null': 'none', 355: '--name-only': 'none', 356: '--show-origin': 'none', 357: '--show-scope': 'none', 358: }, 359: }, 360: 'git remote show': { 361: safeFlags: { 362: '-n': 'none', 363: }, 364: additionalCommandIsDangerousCallback: ( 365: _rawCommand: string, 366: args: string[], 367: ) => { 368: const positional = args.filter(a => a !== '-n') 369: if (positional.length !== 1) return true 370: return !/^[a-zA-Z0-9_-]+$/.test(positional[0]!) 371: }, 372: }, 373: 'git remote': { 374: safeFlags: { 375: '-v': 'none', 376: '--verbose': 'none', 377: }, 378: additionalCommandIsDangerousCallback: ( 379: _rawCommand: string, 380: args: string[], 381: ) => { 382: return args.some(a => a !== '-v' && a !== '--verbose') 383: }, 384: }, 385: 'git merge-base': { 386: safeFlags: { 387: '--is-ancestor': 'none', 388: '--fork-point': 'none', 389: '--octopus': 'none', 390: '--independent': 'none', 391: '--all': 'none', 392: }, 393: }, 394: 'git rev-parse': { 395: safeFlags: { 396: '--verify': 'none', 397: '--short': 'string', 398: '--abbrev-ref': 'none', 399: '--symbolic': 'none', 400: '--symbolic-full-name': 'none', 401: '--show-toplevel': 'none', 402: '--show-cdup': 'none', 403: '--show-prefix': 'none', 404: '--git-dir': 'none', 405: '--git-common-dir': 'none', 406: '--absolute-git-dir': 'none', 407: '--show-superproject-working-tree': 'none', 408: '--is-inside-work-tree': 'none', 409: '--is-inside-git-dir': 'none', 410: '--is-bare-repository': 'none', 411: '--is-shallow-repository': 'none', 412: '--is-shallow-update': 'none', 413: '--path-prefix': 'none', 414: }, 415: }, 416: 'git rev-list': { 417: safeFlags: { 418: ...GIT_REF_SELECTION_FLAGS, 419: ...GIT_DATE_FILTER_FLAGS, 420: ...GIT_COUNT_FLAGS, 421: ...GIT_AUTHOR_FILTER_FLAGS, 422: '--count': 'none', 423: '--reverse': 'none', 424: '--first-parent': 'none', 425: '--ancestry-path': 'none', 426: '--merges': 'none', 427: '--no-merges': 'none', 428: '--min-parents': 'number', 429: '--max-parents': 'number', 430: '--no-min-parents': 'none', 431: '--no-max-parents': 'none', 432: '--skip': 'number', 433: '--max-age': 'number', 434: '--min-age': 'number', 435: '--walk-reflogs': 'none', 436: '--oneline': 'none', 437: '--abbrev-commit': 'none', 438: '--pretty': 'string', 439: '--format': 'string', 440: '--abbrev': 'number', 441: '--full-history': 'none', 442: '--dense': 'none', 443: '--sparse': 'none', 444: '--source': 'none', 445: '--graph': 'none', 446: }, 447: }, 448: 'git describe': { 449: safeFlags: { 450: '--tags': 'none', 451: '--match': 'string', 452: '--exclude': 'string', 453: '--long': 'none', 454: '--abbrev': 'number', 455: '--always': 'none', 456: '--contains': 'none', 457: '--first-match': 'none', 458: '--exact-match': 'none', 459: '--candidates': 'number', 460: '--dirty': 'none', 461: '--broken': 'none', 462: }, 463: }, 464: 'git cat-file': { 465: safeFlags: { 466: '-t': 'none', 467: '-s': 'none', 468: '-p': 'none', 469: '-e': 'none', 470: '--batch-check': 'none', 471: '--allow-undetermined-type': 'none', 472: }, 473: }, 474: 'git for-each-ref': { 475: safeFlags: { 476: '--format': 'string', 477: '--sort': 'string', 478: '--count': 'number', 479: '--contains': 'string', 480: '--no-contains': 'string', 481: '--merged': 'string', 482: '--no-merged': 'string', 483: '--points-at': 'string', 484: }, 485: }, 486: 'git grep': { 487: safeFlags: { 488: '-e': 'string', 489: '-E': 'none', 490: '--extended-regexp': 'none', 491: '-G': 'none', 492: '--basic-regexp': 'none', 493: '-F': 'none', 494: '--fixed-strings': 'none', 495: '-P': 'none', 496: '--perl-regexp': 'none', 497: '-i': 'none', 498: '--ignore-case': 'none', 499: '-v': 'none', 500: '--invert-match': 'none', 501: '-w': 'none', 502: '--word-regexp': 'none', 503: '-n': 'none', 504: '--line-number': 'none', 505: '-c': 'none', 506: '--count': 'none', 507: '-l': 'none', 508: '--files-with-matches': 'none', 509: '-L': 'none', 510: '--files-without-match': 'none', 511: '-h': 'none', 512: '-H': 'none', 513: '--heading': 'none', 514: '--break': 'none', 515: '--full-name': 'none', 516: '--color': 'none', 517: '--no-color': 'none', 518: '-o': 'none', 519: '--only-matching': 'none', 520: '-A': 'number', 521: '--after-context': 'number', 522: '-B': 'number', 523: '--before-context': 'number', 524: '-C': 'number', 525: '--context': 'number', 526: '--and': 'none', 527: '--or': 'none', 528: '--not': 'none', 529: '--max-depth': 'number', 530: '--untracked': 'none', 531: '--no-index': 'none', 532: '--recurse-submodules': 'none', 533: '--cached': 'none', 534: '--threads': 'number', 535: '-q': 'none', 536: '--quiet': 'none', 537: }, 538: }, 539: 'git stash show': { 540: safeFlags: { 541: ...GIT_STAT_FLAGS, 542: ...GIT_COLOR_FLAGS, 543: ...GIT_PATCH_FLAGS, 544: '--word-diff': 'none', 545: '--word-diff-regex': 'string', 546: '--diff-filter': 'string', 547: '--abbrev': 'number', 548: }, 549: }, 550: 'git worktree list': { 551: safeFlags: { 552: '--porcelain': 'none', 553: '-v': 'none', 554: '--verbose': 'none', 555: '--expire': 'string', 556: }, 557: }, 558: 'git tag': { 559: safeFlags: { 560: '-l': 'none', 561: '--list': 'none', 562: '-n': 'number', 563: '--contains': 'string', 564: '--no-contains': 'string', 565: '--merged': 'string', 566: '--no-merged': 'string', 567: '--sort': 'string', 568: '--format': 'string', 569: '--points-at': 'string', 570: '--column': 'none', 571: '--no-column': 'none', 572: '-i': 'none', 573: '--ignore-case': 'none', 574: }, 575: additionalCommandIsDangerousCallback: ( 576: _rawCommand: string, 577: args: string[], 578: ) => { 579: const flagsWithArgs = new Set([ 580: '--contains', 581: '--no-contains', 582: '--merged', 583: '--no-merged', 584: '--points-at', 585: '--sort', 586: '--format', 587: '-n', 588: ]) 589: let i = 0 590: let seenListFlag = false 591: let seenDashDash = false 592: while (i < args.length) { 593: const token = args[i] 594: if (!token) { 595: i++ 596: continue 597: } 598: if (token === '--' && !seenDashDash) { 599: seenDashDash = true 600: i++ 601: continue 602: } 603: if (!seenDashDash && token.startsWith('-')) { 604: if (token === '--list' || token === '-l') { 605: seenListFlag = true 606: } else if ( 607: token[0] === '-' && 608: token[1] !== '-' && 609: token.length > 2 && 610: !token.includes('=') && 611: token.slice(1).includes('l') 612: ) { 613: seenListFlag = true 614: } 615: if (token.includes('=')) { 616: i++ 617: } else if (flagsWithArgs.has(token)) { 618: i += 2 619: } else { 620: i++ 621: } 622: } else { 623: if (!seenListFlag) { 624: return true 625: } 626: i++ 627: } 628: } 629: return false 630: }, 631: }, 632: 'git branch': { 633: safeFlags: { 634: '-l': 'none', 635: '--list': 'none', 636: '-a': 'none', 637: '--all': 'none', 638: '-r': 'none', 639: '--remotes': 'none', 640: '-v': 'none', 641: '-vv': 'none', 642: '--verbose': 'none', 643: '--color': 'none', 644: '--no-color': 'none', 645: '--column': 'none', 646: '--no-column': 'none', 647: '--abbrev': 'number', 648: '--no-abbrev': 'none', 649: '--contains': 'string', 650: '--no-contains': 'string', 651: '--merged': 'none', 652: '--no-merged': 'none', 653: '--points-at': 'string', 654: '--sort': 'string', 655: '--show-current': 'none', 656: '-i': 'none', 657: '--ignore-case': 'none', 658: }, 659: additionalCommandIsDangerousCallback: ( 660: _rawCommand: string, 661: args: string[], 662: ) => { 663: const flagsWithArgs = new Set([ 664: '--contains', 665: '--no-contains', 666: '--points-at', 667: '--sort', 668: ]) 669: const flagsWithOptionalArgs = new Set(['--merged', '--no-merged']) 670: let i = 0 671: let lastFlag = '' 672: let seenListFlag = false 673: let seenDashDash = false 674: while (i < args.length) { 675: const token = args[i] 676: if (!token) { 677: i++ 678: continue 679: } 680: // `--` ends flag parsing. `git branch -- -l` CREATES a branch named `-l`. 681: if (token === '--' && !seenDashDash) { 682: seenDashDash = true 683: lastFlag = '' 684: i++ 685: continue 686: } 687: if (!seenDashDash && token.startsWith('-')) { 688: // Check for -l/--list including short-flag bundles (-li, -la, etc.) 689: if (token === '--list' || token === '-l') { 690: seenListFlag = true 691: } else if ( 692: token[0] === '-' && 693: token[1] !== '-' && 694: token.length > 2 && 695: !token.includes('=') && 696: token.slice(1).includes('l') 697: ) { 698: seenListFlag = true 699: } 700: if (token.includes('=')) { 701: lastFlag = token.split('=')[0] || '' 702: i++ 703: } else if (flagsWithArgs.has(token)) { 704: lastFlag = token 705: i += 2 706: } else { 707: lastFlag = token 708: i++ 709: } 710: } else { 711: // Non-flag argument (or post-`--` positional) - could be: 712: // 1. A branch name (dangerous - creates a branch) 713: // 2. A pattern after --list/-l (safe) 714: // 3. An optional argument after --merged/--no-merged (safe) 715: const lastFlagHasOptionalArg = flagsWithOptionalArgs.has(lastFlag) 716: if (!seenListFlag && !lastFlagHasOptionalArg) { 717: return true // Positional arg without --list or filtering flag = branch creation 718: } 719: i++ 720: } 721: } 722: return false 723: }, 724: }, 725: } 726: // --------------------------------------------------------------------------- 727: // GH_READ_ONLY_COMMANDS — ant-only gh CLI commands (network-dependent) 728: // --------------------------------------------------------------------------- 729: // SECURITY: Shared callback for all gh commands to prevent network exfil. 730: // gh's repo argument accepts `[HOST/]OWNER/REPO` — when HOST is present 731: function ghIsDangerousCallback(_rawCommand: string, args: string[]): boolean { 732: for (const token of args) { 733: if (!token) continue 734: let value = token 735: if (token.startsWith('-')) { 736: const eqIdx = token.indexOf('=') 737: if (eqIdx === -1) continue 738: value = token.slice(eqIdx + 1) 739: if (!value) continue 740: } 741: if ( 742: !value.includes('/') && 743: !value.includes('://') && 744: !value.includes('@') 745: ) { 746: continue 747: } 748: if (value.includes('://')) { 749: return true 750: } 751: if (value.includes('@')) { 752: return true 753: } 754: const slashCount = (value.match(/\//g) || []).length 755: if (slashCount >= 2) { 756: return true 757: } 758: } 759: return false 760: } 761: export const GH_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = { 762: 'gh pr view': { 763: safeFlags: { 764: '--json': 'string', 765: '--comments': 'none', 766: '--repo': 'string', 767: '-R': 'string', 768: }, 769: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 770: }, 771: 'gh pr list': { 772: safeFlags: { 773: '--state': 'string', 774: '-s': 'string', 775: '--author': 'string', 776: '--assignee': 'string', 777: '--label': 'string', 778: '--limit': 'number', 779: '-L': 'number', 780: '--base': 'string', 781: '--head': 'string', 782: '--search': 'string', 783: '--json': 'string', 784: '--draft': 'none', 785: '--app': 'string', 786: '--repo': 'string', 787: '-R': 'string', 788: }, 789: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 790: }, 791: 'gh pr diff': { 792: safeFlags: { 793: '--color': 'string', 794: '--name-only': 'none', 795: '--patch': 'none', 796: '--repo': 'string', 797: '-R': 'string', 798: }, 799: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 800: }, 801: 'gh pr checks': { 802: safeFlags: { 803: '--watch': 'none', 804: '--required': 'none', 805: '--fail-fast': 'none', 806: '--json': 'string', 807: '--interval': 'number', 808: '--repo': 'string', 809: '-R': 'string', 810: }, 811: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 812: }, 813: 'gh issue view': { 814: safeFlags: { 815: '--json': 'string', 816: '--comments': 'none', 817: '--repo': 'string', 818: '-R': 'string', 819: }, 820: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 821: }, 822: 'gh issue list': { 823: safeFlags: { 824: '--state': 'string', 825: '-s': 'string', 826: '--assignee': 'string', 827: '--author': 'string', 828: '--label': 'string', 829: '--limit': 'number', 830: '-L': 'number', 831: '--milestone': 'string', 832: '--search': 'string', 833: '--json': 'string', 834: '--app': 'string', 835: '--repo': 'string', 836: '-R': 'string', 837: }, 838: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 839: }, 840: 'gh repo view': { 841: safeFlags: { 842: '--json': 'string', 843: }, 844: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 845: }, 846: 'gh run list': { 847: safeFlags: { 848: '--branch': 'string', 849: '-b': 'string', 850: '--status': 'string', 851: '-s': 'string', 852: '--workflow': 'string', 853: '-w': 'string', 854: '--limit': 'number', 855: '-L': 'number', 856: '--json': 'string', 857: '--repo': 'string', 858: '-R': 'string', 859: '--event': 'string', 860: '-e': 'string', 861: '--user': 'string', 862: '-u': 'string', 863: '--created': 'string', 864: '--commit': 'string', 865: '-c': 'string', 866: }, 867: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 868: }, 869: 'gh run view': { 870: safeFlags: { 871: '--log': 'none', 872: '--log-failed': 'none', 873: '--exit-status': 'none', 874: '--verbose': 'none', 875: '-v': 'none', 876: '--json': 'string', 877: '--repo': 'string', 878: '-R': 'string', 879: '--job': 'string', 880: '-j': 'string', 881: '--attempt': 'number', 882: '-a': 'number', 883: }, 884: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 885: }, 886: 'gh auth status': { 887: safeFlags: { 888: '--active': 'none', 889: '-a': 'none', 890: '--hostname': 'string', 891: '-h': 'string', 892: '--json': 'string', 893: }, 894: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 895: }, 896: 'gh pr status': { 897: safeFlags: { 898: '--conflict-status': 'none', 899: '-c': 'none', 900: '--json': 'string', 901: '--repo': 'string', 902: '-R': 'string', 903: }, 904: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 905: }, 906: 'gh issue status': { 907: safeFlags: { 908: '--json': 'string', 909: '--repo': 'string', 910: '-R': 'string', 911: }, 912: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 913: }, 914: 'gh release list': { 915: safeFlags: { 916: '--exclude-drafts': 'none', 917: '--exclude-pre-releases': 'none', 918: '--json': 'string', 919: '--limit': 'number', 920: '-L': 'number', 921: '--order': 'string', 922: '-O': 'string', 923: '--repo': 'string', 924: '-R': 'string', 925: }, 926: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 927: }, 928: 'gh release view': { 929: safeFlags: { 930: '--json': 'string', 931: '--repo': 'string', 932: '-R': 'string', 933: }, 934: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 935: }, 936: 'gh workflow list': { 937: safeFlags: { 938: '--all': 'none', 939: '-a': 'none', 940: '--json': 'string', 941: '--limit': 'number', 942: '-L': 'number', 943: '--repo': 'string', 944: '-R': 'string', 945: }, 946: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 947: }, 948: 'gh workflow view': { 949: safeFlags: { 950: '--ref': 'string', 951: '-r': 'string', 952: '--yaml': 'none', 953: '-y': 'none', 954: '--repo': 'string', 955: '-R': 'string', 956: }, 957: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 958: }, 959: 'gh label list': { 960: safeFlags: { 961: '--json': 'string', 962: '--limit': 'number', 963: '-L': 'number', 964: '--order': 'string', 965: '--search': 'string', 966: '-S': 'string', 967: '--sort': 'string', 968: '--repo': 'string', 969: '-R': 'string', 970: }, 971: additionalCommandIsDangerousCallback: ghIsDangerousCallback, 972: }, 973: 'gh search repos': { 974: safeFlags: { 975: '--archived': 'none', 976: '--created': 'string', 977: '--followers': 'string', 978: '--forks': 'string', 979: '--good-first-issues': 'string', 980: '--help-wanted-issues': 'string', 981: '--include-forks': 'string', 982: '--json': 'string', 983: '--language': 'string', 984: '--license': 'string', 985: '--limit': 'number', 986: '-L': 'number', 987: '--match': 'string', 988: '--number-topics': 'string', 989: '--order': 'string', 990: '--owner': 'string', 991: '--size': 'string', 992: '--sort': 'string', 993: '--stars': 'string', 994: '--topic': 'string', 995: '--updated': 'string', 996: '--visibility': 'string', 997: }, 998: }, 999: 'gh search issues': { 1000: safeFlags: { 1001: '--app': 'string', 1002: '--assignee': 'string', 1003: '--author': 'string', 1004: '--closed': 'string', 1005: '--commenter': 'string', 1006: '--comments': 'string', 1007: '--created': 'string', 1008: '--include-prs': 'none', 1009: '--interactions': 'string', 1010: '--involves': 'string', 1011: '--json': 'string', 1012: '--label': 'string', 1013: '--language': 'string', 1014: '--limit': 'number', 1015: '-L': 'number', 1016: '--locked': 'none', 1017: '--match': 'string', 1018: '--mentions': 'string', 1019: '--milestone': 'string', 1020: '--no-assignee': 'none', 1021: '--no-label': 'none', 1022: '--no-milestone': 'none', 1023: '--no-project': 'none', 1024: '--order': 'string', 1025: '--owner': 'string', 1026: '--project': 'string', 1027: '--reactions': 'string', 1028: '--repo': 'string', 1029: '-R': 'string', 1030: '--sort': 'string', 1031: '--state': 'string', 1032: '--team-mentions': 'string', 1033: '--updated': 'string', 1034: '--visibility': 'string', 1035: }, 1036: }, 1037: 'gh search prs': { 1038: safeFlags: { 1039: '--app': 'string', 1040: '--assignee': 'string', 1041: '--author': 'string', 1042: '--base': 'string', 1043: '-B': 'string', 1044: '--checks': 'string', 1045: '--closed': 'string', 1046: '--commenter': 'string', 1047: '--comments': 'string', 1048: '--created': 'string', 1049: '--draft': 'none', 1050: '--head': 'string', 1051: '-H': 'string', 1052: '--interactions': 'string', 1053: '--involves': 'string', 1054: '--json': 'string', 1055: '--label': 'string', 1056: '--language': 'string', 1057: '--limit': 'number', 1058: '-L': 'number', 1059: '--locked': 'none', 1060: '--match': 'string', 1061: '--mentions': 'string', 1062: '--merged': 'none', 1063: '--merged-at': 'string', 1064: '--milestone': 'string', 1065: '--no-assignee': 'none', 1066: '--no-label': 'none', 1067: '--no-milestone': 'none', 1068: '--no-project': 'none', 1069: '--order': 'string', 1070: '--owner': 'string', 1071: '--project': 'string', 1072: '--reactions': 'string', 1073: '--repo': 'string', 1074: '-R': 'string', 1075: '--review': 'string', 1076: '--review-requested': 'string', 1077: '--reviewed-by': 'string', 1078: '--sort': 'string', 1079: '--state': 'string', 1080: '--team-mentions': 'string', 1081: '--updated': 'string', 1082: '--visibility': 'string', 1083: }, 1084: }, 1085: 'gh search commits': { 1086: safeFlags: { 1087: '--author': 'string', 1088: '--author-date': 'string', 1089: '--author-email': 'string', 1090: '--author-name': 'string', 1091: '--committer': 'string', 1092: '--committer-date': 'string', 1093: '--committer-email': 'string', 1094: '--committer-name': 'string', 1095: '--hash': 'string', 1096: '--json': 'string', 1097: '--limit': 'number', 1098: '-L': 'number', 1099: '--merge': 'none', 1100: '--order': 'string', 1101: '--owner': 'string', 1102: '--parent': 'string', 1103: '--repo': 'string', 1104: '-R': 'string', 1105: '--sort': 'string', 1106: '--tree': 'string', 1107: '--visibility': 'string', 1108: }, 1109: }, 1110: 'gh search code': { 1111: safeFlags: { 1112: '--extension': 'string', 1113: '--filename': 'string', 1114: '--json': 'string', 1115: '--language': 'string', 1116: '--limit': 'number', 1117: '-L': 'number', 1118: '--match': 'string', 1119: '--owner': 'string', 1120: '--repo': 'string', 1121: '-R': 'string', 1122: '--size': 'string', 1123: }, 1124: }, 1125: } 1126: export const DOCKER_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = 1127: { 1128: 'docker logs': { 1129: safeFlags: { 1130: '--follow': 'none', 1131: '-f': 'none', 1132: '--tail': 'string', 1133: '-n': 'string', 1134: '--timestamps': 'none', 1135: '-t': 'none', 1136: '--since': 'string', 1137: '--until': 'string', 1138: '--details': 'none', 1139: }, 1140: }, 1141: 'docker inspect': { 1142: safeFlags: { 1143: '--format': 'string', 1144: '-f': 'string', 1145: '--type': 'string', 1146: '--size': 'none', 1147: '-s': 'none', 1148: }, 1149: }, 1150: } 1151: export const RIPGREP_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = 1152: { 1153: rg: { 1154: safeFlags: { 1155: '-e': 'string', 1156: '--regexp': 'string', 1157: '-f': 'string', 1158: '-i': 'none', 1159: '--ignore-case': 'none', 1160: '-S': 'none', 1161: '--smart-case': 'none', 1162: '-F': 'none', 1163: '--fixed-strings': 'none', 1164: '-w': 'none', 1165: '--word-regexp': 'none', 1166: '-v': 'none', 1167: '--invert-match': 'none', 1168: '-c': 'none', 1169: '--count': 'none', 1170: '-l': 'none', 1171: '--files-with-matches': 'none', 1172: '--files-without-match': 'none', 1173: '-n': 'none', 1174: '--line-number': 'none', 1175: '-o': 'none', 1176: '--only-matching': 'none', 1177: '-A': 'number', 1178: '--after-context': 'number', 1179: '-B': 'number', 1180: '--before-context': 'number', 1181: '-C': 'number', 1182: '--context': 'number', 1183: '-H': 'none', 1184: '-h': 'none', 1185: '--heading': 'none', 1186: '--no-heading': 'none', 1187: '-q': 'none', 1188: '--quiet': 'none', 1189: '--column': 'none', 1190: '-g': 'string', 1191: '--glob': 'string', 1192: '-t': 'string', 1193: '--type': 'string', 1194: '-T': 'string', 1195: '--type-not': 'string', 1196: '--type-list': 'none', 1197: '--hidden': 'none', 1198: '--no-ignore': 'none', 1199: '-u': 'none', 1200: '-m': 'number', 1201: '--max-count': 'number', 1202: '-d': 'number', 1203: '--max-depth': 'number', 1204: '-a': 'none', 1205: '--text': 'none', 1206: '-z': 'none', 1207: '-L': 'none', 1208: '--follow': 'none', 1209: '--color': 'string', 1210: '--json': 'none', 1211: '--stats': 'none', 1212: '--help': 'none', 1213: '--version': 'none', 1214: '--debug': 'none', 1215: '--': 'none', 1216: }, 1217: }, 1218: } 1219: export const PYRIGHT_READ_ONLY_COMMANDS: Record<string, ExternalCommandConfig> = 1220: { 1221: pyright: { 1222: respectsDoubleDash: false, 1223: safeFlags: { 1224: '--outputjson': 'none', 1225: '--project': 'string', 1226: '-p': 'string', 1227: '--pythonversion': 'string', 1228: '--pythonplatform': 'string', 1229: '--typeshedpath': 'string', 1230: '--venvpath': 'string', 1231: '--level': 'string', 1232: '--stats': 'none', 1233: '--verbose': 'none', 1234: '--version': 'none', 1235: '--dependencies': 'none', 1236: '--warnings': 'none', 1237: }, 1238: additionalCommandIsDangerousCallback: ( 1239: _rawCommand: string, 1240: args: string[], 1241: ) => { 1242: return args.some(t => t === '--watch' || t === '-w') 1243: }, 1244: }, 1245: } 1246: export const EXTERNAL_READONLY_COMMANDS: readonly string[] = [ 1247: 'docker ps', 1248: 'docker images', 1249: ] as const 1250: export function containsVulnerableUncPath(pathOrCommand: string): boolean { 1251: if (getPlatform() !== 'windows') { 1252: return false 1253: } 1254: const backslashUncPattern = /\\\\[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i 1255: if (backslashUncPattern.test(pathOrCommand)) { 1256: return true 1257: } 1258: const forwardSlashUncPattern = 1259: /(?<!:)\/\/[^\s\\/]+(?:@(?:\d+|ssl))?(?:[\\/]|$|\s)/i 1260: if (forwardSlashUncPattern.test(pathOrCommand)) { 1261: return true 1262: } 1263: const mixedSlashUncPattern = /\/\\{2,}[^\s\\/]/ 1264: if (mixedSlashUncPattern.test(pathOrCommand)) { 1265: return true 1266: } 1267: const reverseMixedSlashUncPattern = /\\{2,}\/[^\s\\/]/ 1268: if (reverseMixedSlashUncPattern.test(pathOrCommand)) { 1269: return true 1270: } 1271: if (/@SSL@\d+/i.test(pathOrCommand) || /@\d+@SSL/i.test(pathOrCommand)) { 1272: return true 1273: } 1274: if (/DavWWWRoot/i.test(pathOrCommand)) { 1275: return true 1276: } 1277: if ( 1278: /^\\\\(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[\\/]/.test(pathOrCommand) || 1279: /^\/\/(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})[\\/]/.test(pathOrCommand) 1280: ) { 1281: return true 1282: } 1283: if ( 1284: /^\\\\(\[[\da-fA-F:]+\])[\\/]/.test(pathOrCommand) || 1285: /^\/\/(\[[\da-fA-F:]+\])[\\/]/.test(pathOrCommand) 1286: ) { 1287: return true 1288: } 1289: return false 1290: } 1291: export const FLAG_PATTERN = /^-[a-zA-Z0-9_-]/ 1292: export function validateFlagArgument( 1293: value: string, 1294: argType: FlagArgType, 1295: ): boolean { 1296: switch (argType) { 1297: case 'none': 1298: return false 1299: case 'number': 1300: return /^\d+$/.test(value) 1301: case 'string': 1302: return true 1303: case 'char': 1304: return value.length === 1 1305: case '{}': 1306: return value === '{}' 1307: case 'EOF': 1308: return value === 'EOF' 1309: default: 1310: return false 1311: } 1312: } 1313: export function validateFlags( 1314: tokens: string[], 1315: startIndex: number, 1316: config: ExternalCommandConfig, 1317: options?: { 1318: commandName?: string 1319: rawCommand?: string 1320: xargsTargetCommands?: string[] 1321: }, 1322: ): boolean { 1323: let i = startIndex 1324: while (i < tokens.length) { 1325: let token = tokens[i] 1326: if (!token) { 1327: i++ 1328: continue 1329: } 1330: if ( 1331: options?.xargsTargetCommands && 1332: options.commandName === 'xargs' && 1333: (!token.startsWith('-') || token === '--') 1334: ) { 1335: if (token === '--' && i + 1 < tokens.length) { 1336: i++ 1337: token = tokens[i] 1338: } 1339: if (token && options.xargsTargetCommands.includes(token)) { 1340: break 1341: } 1342: return false 1343: } 1344: if (token === '--') { 1345: if (config.respectsDoubleDash !== false) { 1346: i++ 1347: break 1348: } 1349: i++ 1350: continue 1351: } 1352: if (token.startsWith('-') && token.length > 1 && FLAG_PATTERN.test(token)) { 1353: const hasEquals = token.includes('=') 1354: const [flag, ...valueParts] = token.split('=') 1355: const inlineValue = valueParts.join('=') 1356: if (!flag) { 1357: return false 1358: } 1359: const flagArgType = config.safeFlags[flag] 1360: if (!flagArgType) { 1361: if (options?.commandName === 'git' && flag.match(/^-\d+$/)) { 1362: i++ 1363: continue 1364: } 1365: if ( 1366: (options?.commandName === 'grep' || options?.commandName === 'rg') && 1367: flag.startsWith('-') && 1368: !flag.startsWith('--') && 1369: flag.length > 2 1370: ) { 1371: const potentialFlag = flag.substring(0, 2) 1372: const potentialValue = flag.substring(2) 1373: if (config.safeFlags[potentialFlag] && /^\d+$/.test(potentialValue)) { 1374: const flagArgType = config.safeFlags[potentialFlag] 1375: if (flagArgType === 'number' || flagArgType === 'string') { 1376: if (validateFlagArgument(potentialValue, flagArgType)) { 1377: i++ 1378: continue 1379: } else { 1380: return false 1381: } 1382: } 1383: } 1384: } 1385: if (flag.startsWith('-') && !flag.startsWith('--') && flag.length > 2) { 1386: for (let j = 1; j < flag.length; j++) { 1387: const singleFlag = '-' + flag[j] 1388: const flagType = config.safeFlags[singleFlag] 1389: if (!flagType) { 1390: return false 1391: } 1392: if (flagType !== 'none') { 1393: return false 1394: } 1395: } 1396: i++ 1397: continue 1398: } else { 1399: return false 1400: } 1401: } 1402: if (flagArgType === 'none') { 1403: if (hasEquals) { 1404: return false 1405: } 1406: i++ 1407: } else { 1408: let argValue: string 1409: if (hasEquals) { 1410: argValue = inlineValue 1411: i++ 1412: } else { 1413: if ( 1414: i + 1 >= tokens.length || 1415: (tokens[i + 1] && 1416: tokens[i + 1]!.startsWith('-') && 1417: tokens[i + 1]!.length > 1 && 1418: FLAG_PATTERN.test(tokens[i + 1]!)) 1419: ) { 1420: return false 1421: } 1422: argValue = tokens[i + 1] || '' 1423: i += 2 1424: } 1425: // Defense-in-depth: For string arguments, reject values that start with '-' 1426: // This prevents type confusion attacks where a flag marked as 'string' 1427: if (flagArgType === 'string' && argValue.startsWith('-')) { 1428: if ( 1429: flag === '--sort' && 1430: options?.commandName === 'git' && 1431: argValue.match(/^-[a-zA-Z]/) 1432: ) { 1433: } else { 1434: return false 1435: } 1436: } 1437: if (!validateFlagArgument(argValue, flagArgType)) { 1438: return false 1439: } 1440: } 1441: } else { 1442: i++ 1443: } 1444: } 1445: return true 1446: }

File: src/utils/shell/resolveDefaultShell.ts

typescript 1: import { getInitialSettings } from '../settings/settings.js' 2: export function resolveDefaultShell(): 'bash' | 'powershell' { 3: return getInitialSettings().defaultShell ?? 'bash' 4: }

File: src/utils/shell/shellProvider.ts

typescript 1: export const SHELL_TYPES = ['bash', 'powershell'] as const 2: export type ShellType = (typeof SHELL_TYPES)[number] 3: export const DEFAULT_HOOK_SHELL: ShellType = 'bash' 4: export type ShellProvider = { 5: type: ShellType 6: shellPath: string 7: detached: boolean 8: buildExecCommand( 9: command: string, 10: opts: { 11: id: number | string 12: sandboxTmpDir?: string 13: useSandbox: boolean 14: }, 15: ): Promise<{ commandString: string; cwdFilePath: string }> 16: getSpawnArgs(commandString: string): string[] 17: getEnvironmentOverrides(command: string): Promise<Record<string, string>> 18: }

File: src/utils/shell/shellToolUtils.ts

typescript 1: import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 2: import { POWERSHELL_TOOL_NAME } from '../../tools/PowerShellTool/toolName.js' 3: import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js' 4: import { getPlatform } from '../platform.js' 5: export const SHELL_TOOL_NAMES: string[] = [BASH_TOOL_NAME, POWERSHELL_TOOL_NAME] 6: export function isPowerShellToolEnabled(): boolean { 7: if (getPlatform() !== 'windows') return false 8: return process.env.USER_TYPE === 'ant' 9: ? !isEnvDefinedFalsy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL) 10: : isEnvTruthy(process.env.CLAUDE_CODE_USE_POWERSHELL_TOOL) 11: }

File: src/utils/shell/specPrefix.ts

typescript 1: import type { CommandSpec } from '../bash/registry.js' 2: const URL_PROTOCOLS = ['http://', 'https://', 'ftp://'] 3: export const DEPTH_RULES: Record<string, number> = { 4: rg: 2, 5: 'pre-commit': 2, 6: gcloud: 4, 7: 'gcloud compute': 6, 8: 'gcloud beta': 6, 9: aws: 4, 10: az: 4, 11: kubectl: 3, 12: docker: 3, 13: dotnet: 3, 14: 'git push': 2, 15: } 16: const toArray = <T>(val: T | T[]): T[] => (Array.isArray(val) ? val : [val]) 17: function isKnownSubcommand(arg: string, spec: CommandSpec | null): boolean { 18: if (!spec?.subcommands?.length) return false 19: const argLower = arg.toLowerCase() 20: return spec.subcommands.some(sub => 21: Array.isArray(sub.name) 22: ? sub.name.some(n => n.toLowerCase() === argLower) 23: : sub.name.toLowerCase() === argLower, 24: ) 25: } 26: function flagTakesArg( 27: flag: string, 28: nextArg: string | undefined, 29: spec: CommandSpec | null, 30: ): boolean { 31: if (spec?.options) { 32: const option = spec.options.find(opt => 33: Array.isArray(opt.name) ? opt.name.includes(flag) : opt.name === flag, 34: ) 35: if (option) return !!option.args 36: } 37: if (spec?.subcommands?.length && nextArg && !nextArg.startsWith('-')) { 38: return !isKnownSubcommand(nextArg, spec) 39: } 40: return false 41: } 42: function findFirstSubcommand( 43: args: string[], 44: spec: CommandSpec | null, 45: ): string | undefined { 46: for (let i = 0; i < args.length; i++) { 47: const arg = args[i] 48: if (!arg) continue 49: if (arg.startsWith('-')) { 50: if (flagTakesArg(arg, args[i + 1], spec)) i++ 51: continue 52: } 53: if (!spec?.subcommands?.length) return arg 54: if (isKnownSubcommand(arg, spec)) return arg 55: } 56: return undefined 57: } 58: export async function buildPrefix( 59: command: string, 60: args: string[], 61: spec: CommandSpec | null, 62: ): Promise<string> { 63: const maxDepth = await calculateDepth(command, args, spec) 64: const parts = [command] 65: const hasSubcommands = !!spec?.subcommands?.length 66: let foundSubcommand = false 67: for (let i = 0; i < args.length; i++) { 68: const arg = args[i] 69: if (!arg || parts.length >= maxDepth) break 70: if (arg.startsWith('-')) { 71: if (arg === '-c' && ['python', 'python3'].includes(command.toLowerCase())) 72: break 73: if (spec?.options) { 74: const option = spec.options.find(opt => 75: Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg, 76: ) 77: if ( 78: option?.args && 79: toArray(option.args).some(a => a?.isCommand || a?.isModule) 80: ) { 81: parts.push(arg) 82: continue 83: } 84: } 85: if (hasSubcommands && !foundSubcommand) { 86: if (flagTakesArg(arg, args[i + 1], spec)) i++ 87: continue 88: } 89: break 90: } 91: if (await shouldStopAtArg(arg, args.slice(0, i), spec)) break 92: if (hasSubcommands && !foundSubcommand) { 93: foundSubcommand = isKnownSubcommand(arg, spec) 94: } 95: parts.push(arg) 96: } 97: return parts.join(' ') 98: } 99: async function calculateDepth( 100: command: string, 101: args: string[], 102: spec: CommandSpec | null, 103: ): Promise<number> { 104: const firstSubcommand = findFirstSubcommand(args, spec) 105: const commandLower = command.toLowerCase() 106: const key = firstSubcommand 107: ? `${commandLower} ${firstSubcommand.toLowerCase()}` 108: : commandLower 109: if (DEPTH_RULES[key]) return DEPTH_RULES[key] 110: if (DEPTH_RULES[commandLower]) return DEPTH_RULES[commandLower] 111: if (!spec) return 2 112: if (spec.options && args.some(arg => arg?.startsWith('-'))) { 113: for (const arg of args) { 114: if (!arg?.startsWith('-')) continue 115: const option = spec.options.find(opt => 116: Array.isArray(opt.name) ? opt.name.includes(arg) : opt.name === arg, 117: ) 118: if ( 119: option?.args && 120: toArray(option.args).some(arg => arg?.isCommand || arg?.isModule) 121: ) 122: return 3 123: } 124: } 125: if (firstSubcommand && spec.subcommands?.length) { 126: const firstSubLower = firstSubcommand.toLowerCase() 127: const subcommand = spec.subcommands.find(sub => 128: Array.isArray(sub.name) 129: ? sub.name.some(n => n.toLowerCase() === firstSubLower) 130: : sub.name.toLowerCase() === firstSubLower, 131: ) 132: if (subcommand) { 133: if (subcommand.args) { 134: const subArgs = toArray(subcommand.args) 135: if (subArgs.some(arg => arg?.isCommand)) return 3 136: if (subArgs.some(arg => arg?.isVariadic)) return 2 137: } 138: if (subcommand.subcommands?.length) return 4 139: if (!subcommand.args) return 2 140: return 3 141: } 142: } 143: if (spec.args) { 144: const argsArray = toArray(spec.args) 145: if (argsArray.some(arg => arg?.isCommand)) { 146: return !Array.isArray(spec.args) && spec.args.isCommand 147: ? 2 148: : Math.min(2 + argsArray.findIndex(arg => arg?.isCommand), 3) 149: } 150: if (!spec.subcommands?.length) { 151: if (argsArray.some(arg => arg?.isVariadic)) return 1 152: if (argsArray[0] && !argsArray[0].isOptional) return 2 153: } 154: } 155: return spec.args && toArray(spec.args).some(arg => arg?.isDangerous) ? 3 : 2 156: } 157: async function shouldStopAtArg( 158: arg: string, 159: args: string[], 160: spec: CommandSpec | null, 161: ): Promise<boolean> { 162: if (arg.startsWith('-')) return true 163: const dotIndex = arg.lastIndexOf('.') 164: const hasExtension = 165: dotIndex > 0 && 166: dotIndex < arg.length - 1 && 167: !arg.substring(dotIndex + 1).includes(':') 168: const hasFile = arg.includes('/') || hasExtension 169: const hasUrl = URL_PROTOCOLS.some(proto => arg.startsWith(proto)) 170: if (!hasFile && !hasUrl) return false 171: if (spec?.options && args.length > 0 && args[args.length - 1] === '-m') { 172: const option = spec.options.find(opt => 173: Array.isArray(opt.name) ? opt.name.includes('-m') : opt.name === '-m', 174: ) 175: if (option?.args && toArray(option.args).some(arg => arg?.isModule)) { 176: return false 177: } 178: } 179: return true 180: }

File: src/utils/skills/skillChangeDetector.ts

typescript 1: import chokidar, { type FSWatcher } from 'chokidar' 2: import * as platformPath from 'path' 3: import { getAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js' 4: import { 5: clearCommandMemoizationCaches, 6: clearCommandsCache, 7: } from '../../commands.js' 8: import { 9: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 10: logEvent, 11: } from '../../services/analytics/index.js' 12: import { 13: clearSkillCaches, 14: getSkillsPath, 15: onDynamicSkillsLoaded, 16: } from '../../skills/loadSkillsDir.js' 17: import { resetSentSkillNames } from '../attachments.js' 18: import { registerCleanup } from '../cleanupRegistry.js' 19: import { logForDebugging } from '../debug.js' 20: import { getFsImplementation } from '../fsOperations.js' 21: import { executeConfigChangeHooks, hasBlockingResult } from '../hooks.js' 22: import { createSignal } from '../signal.js' 23: const FILE_STABILITY_THRESHOLD_MS = 1000 24: const FILE_STABILITY_POLL_INTERVAL_MS = 500 25: const RELOAD_DEBOUNCE_MS = 300 26: const POLLING_INTERVAL_MS = 2000 27: const USE_POLLING = typeof Bun !== 'undefined' 28: let watcher: FSWatcher | null = null 29: let reloadTimer: ReturnType<typeof setTimeout> | null = null 30: const pendingChangedPaths = new Set<string>() 31: let initialized = false 32: let disposed = false 33: let dynamicSkillsCallbackRegistered = false 34: let unregisterCleanup: (() => void) | null = null 35: const skillsChanged = createSignal() 36: let testOverrides: { 37: stabilityThreshold?: number 38: pollInterval?: number 39: reloadDebounce?: number 40: chokidarInterval?: number 41: } | null = null 42: export async function initialize(): Promise<void> { 43: if (initialized || disposed) return 44: initialized = true 45: if (!dynamicSkillsCallbackRegistered) { 46: dynamicSkillsCallbackRegistered = true 47: onDynamicSkillsLoaded(() => { 48: clearCommandMemoizationCaches() 49: skillsChanged.emit() 50: }) 51: } 52: const paths = await getWatchablePaths() 53: if (paths.length === 0) return 54: logForDebugging( 55: `Watching for changes in skill/command directories: ${paths.join(', ')}...`, 56: ) 57: watcher = chokidar.watch(paths, { 58: persistent: true, 59: ignoreInitial: true, 60: depth: 2, 61: awaitWriteFinish: { 62: stabilityThreshold: 63: testOverrides?.stabilityThreshold ?? FILE_STABILITY_THRESHOLD_MS, 64: pollInterval: 65: testOverrides?.pollInterval ?? FILE_STABILITY_POLL_INTERVAL_MS, 66: }, 67: ignored: (path, stats) => { 68: if (stats && !stats.isFile() && !stats.isDirectory()) return true 69: return path.split(platformPath.sep).some(dir => dir === '.git') 70: }, 71: ignorePermissionErrors: true, 72: usePolling: USE_POLLING, 73: interval: testOverrides?.chokidarInterval ?? POLLING_INTERVAL_MS, 74: atomic: true, 75: }) 76: watcher.on('add', handleChange) 77: watcher.on('change', handleChange) 78: watcher.on('unlink', handleChange) 79: unregisterCleanup = registerCleanup(async () => { 80: await dispose() 81: }) 82: } 83: export function dispose(): Promise<void> { 84: disposed = true 85: if (unregisterCleanup) { 86: unregisterCleanup() 87: unregisterCleanup = null 88: } 89: let closePromise: Promise<void> = Promise.resolve() 90: if (watcher) { 91: closePromise = watcher.close() 92: watcher = null 93: } 94: if (reloadTimer) { 95: clearTimeout(reloadTimer) 96: reloadTimer = null 97: } 98: pendingChangedPaths.clear() 99: skillsChanged.clear() 100: return closePromise 101: } 102: export const subscribe = skillsChanged.subscribe 103: async function getWatchablePaths(): Promise<string[]> { 104: const fs = getFsImplementation() 105: const paths: string[] = [] 106: const userSkillsPath = getSkillsPath('userSettings', 'skills') 107: if (userSkillsPath) { 108: try { 109: await fs.stat(userSkillsPath) 110: paths.push(userSkillsPath) 111: } catch { 112: } 113: } 114: const userCommandsPath = getSkillsPath('userSettings', 'commands') 115: if (userCommandsPath) { 116: try { 117: await fs.stat(userCommandsPath) 118: paths.push(userCommandsPath) 119: } catch { 120: } 121: } 122: const projectSkillsPath = getSkillsPath('projectSettings', 'skills') 123: if (projectSkillsPath) { 124: try { 125: const absolutePath = platformPath.resolve(projectSkillsPath) 126: await fs.stat(absolutePath) 127: paths.push(absolutePath) 128: } catch { 129: } 130: } 131: const projectCommandsPath = getSkillsPath('projectSettings', 'commands') 132: if (projectCommandsPath) { 133: try { 134: const absolutePath = platformPath.resolve(projectCommandsPath) 135: await fs.stat(absolutePath) 136: paths.push(absolutePath) 137: } catch { 138: } 139: } 140: for (const dir of getAdditionalDirectoriesForClaudeMd()) { 141: const additionalSkillsPath = platformPath.join(dir, '.claude', 'skills') 142: try { 143: await fs.stat(additionalSkillsPath) 144: paths.push(additionalSkillsPath) 145: } catch { 146: } 147: } 148: return paths 149: } 150: function handleChange(path: string): void { 151: logForDebugging(`Detected skill change: ${path}`) 152: logEvent('tengu_skill_file_changed', { 153: source: 154: 'chokidar' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 155: }) 156: scheduleReload(path) 157: } 158: function scheduleReload(changedPath: string): void { 159: pendingChangedPaths.add(changedPath) 160: if (reloadTimer) clearTimeout(reloadTimer) 161: reloadTimer = setTimeout(async () => { 162: reloadTimer = null 163: const paths = [...pendingChangedPaths] 164: pendingChangedPaths.clear() 165: const results = await executeConfigChangeHooks('skills', paths[0]!) 166: if (hasBlockingResult(results)) { 167: logForDebugging( 168: `ConfigChange hook blocked skill reload (${paths.length} paths)`, 169: ) 170: return 171: } 172: clearSkillCaches() 173: clearCommandsCache() 174: resetSentSkillNames() 175: skillsChanged.emit() 176: }, testOverrides?.reloadDebounce ?? RELOAD_DEBOUNCE_MS) 177: } 178: export async function resetForTesting(overrides?: { 179: stabilityThreshold?: number 180: pollInterval?: number 181: reloadDebounce?: number 182: chokidarInterval?: number 183: }): Promise<void> { 184: if (watcher) { 185: await watcher.close() 186: watcher = null 187: } 188: if (reloadTimer) { 189: clearTimeout(reloadTimer) 190: reloadTimer = null 191: } 192: pendingChangedPaths.clear() 193: skillsChanged.clear() 194: initialized = false 195: disposed = false 196: testOverrides = overrides ?? null 197: } 198: export const skillChangeDetector = { 199: initialize, 200: dispose, 201: subscribe, 202: resetForTesting, 203: }

File: src/utils/suggestions/commandSuggestions.ts

typescript 1: import Fuse from 'fuse.js' 2: import { 3: type Command, 4: formatDescriptionWithSource, 5: getCommand, 6: getCommandName, 7: } from '../../commands.js' 8: import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js' 9: import { getSkillUsageScore } from './skillUsageTracking.js' 10: const SEPARATORS = /[:_-]/g 11: type CommandSearchItem = { 12: descriptionKey: string[] 13: partKey: string[] | undefined 14: commandName: string 15: command: Command 16: aliasKey: string[] | undefined 17: } 18: let fuseCache: { 19: commands: Command[] 20: fuse: Fuse<CommandSearchItem> 21: } | null = null 22: function getCommandFuse(commands: Command[]): Fuse<CommandSearchItem> { 23: if (fuseCache?.commands === commands) { 24: return fuseCache.fuse 25: } 26: const commandData: CommandSearchItem[] = commands 27: .filter(cmd => !cmd.isHidden) 28: .map(cmd => { 29: const commandName = getCommandName(cmd) 30: const parts = commandName.split(SEPARATORS).filter(Boolean) 31: return { 32: descriptionKey: (cmd.description ?? '') 33: .split(' ') 34: .map(word => cleanWord(word)) 35: .filter(Boolean), 36: partKey: parts.length > 1 ? parts : undefined, 37: commandName, 38: command: cmd, 39: aliasKey: cmd.aliases, 40: } 41: }) 42: const fuse = new Fuse(commandData, { 43: includeScore: true, 44: threshold: 0.3, // relatively strict matching 45: location: 0, // prefer matches at the beginning of strings 46: distance: 100, // increased to allow matching in descriptions 47: keys: [ 48: { 49: name: 'commandName', 50: weight: 3, 51: }, 52: { 53: name: 'partKey', 54: weight: 2, 55: }, 56: { 57: name: 'aliasKey', 58: weight: 2, 59: }, 60: { 61: name: 'descriptionKey', 62: weight: 0.5, 63: }, 64: ], 65: }) 66: fuseCache = { commands, fuse } 67: return fuse 68: } 69: function isCommandMetadata(metadata: unknown): metadata is Command { 70: return ( 71: typeof metadata === 'object' && 72: metadata !== null && 73: 'name' in metadata && 74: typeof (metadata as { name: unknown }).name === 'string' && 75: 'type' in metadata 76: ) 77: } 78: export type MidInputSlashCommand = { 79: token: string 80: startPos: number 81: partialCommand: string 82: } 83: export function findMidInputSlashCommand( 84: input: string, 85: cursorOffset: number, 86: ): MidInputSlashCommand | null { 87: if (input.startsWith('/')) { 88: return null 89: } 90: const beforeCursor = input.slice(0, cursorOffset) 91: const match = beforeCursor.match(/\s\/([a-zA-Z0-9_:-]*)$/) 92: if (!match || match.index === undefined) { 93: return null 94: } 95: const slashPos = match.index + 1 96: const textAfterSlash = input.slice(slashPos + 1) 97: const commandMatch = textAfterSlash.match(/^[a-zA-Z0-9_:-]*/) 98: const fullCommand = commandMatch ? commandMatch[0] : '' 99: // If cursor is past the command (after a space), don't show ghost text 100: if (cursorOffset > slashPos + 1 + fullCommand.length) { 101: return null 102: } 103: return { 104: token: '/' + fullCommand, 105: startPos: slashPos, 106: partialCommand: fullCommand, 107: } 108: } 109: export function getBestCommandMatch( 110: partialCommand: string, 111: commands: Command[], 112: ): { suffix: string; fullCommand: string } | null { 113: if (!partialCommand) { 114: return null 115: } 116: const suggestions = generateCommandSuggestions('/' + partialCommand, commands) 117: if (suggestions.length === 0) { 118: return null 119: } 120: const query = partialCommand.toLowerCase() 121: for (const suggestion of suggestions) { 122: if (!isCommandMetadata(suggestion.metadata)) { 123: continue 124: } 125: const name = getCommandName(suggestion.metadata) 126: if (name.toLowerCase().startsWith(query)) { 127: const suffix = name.slice(partialCommand.length) 128: if (suffix) { 129: return { suffix, fullCommand: name } 130: } 131: } 132: } 133: return null 134: } 135: export function isCommandInput(input: string): boolean { 136: return input.startsWith('/') 137: } 138: export function hasCommandArgs(input: string): boolean { 139: if (!isCommandInput(input)) return false 140: if (!input.includes(' ')) return false 141: if (input.endsWith(' ')) return false 142: return true 143: } 144: export function formatCommand(command: string): string { 145: return `/${command} ` 146: } 147: function getCommandId(cmd: Command): string { 148: const commandName = getCommandName(cmd) 149: if (cmd.type === 'prompt') { 150: if (cmd.source === 'plugin' && cmd.pluginInfo?.repository) { 151: return `${commandName}:${cmd.source}:${cmd.pluginInfo.repository}` 152: } 153: return `${commandName}:${cmd.source}` 154: } 155: return `${commandName}:${cmd.type}` 156: } 157: function findMatchedAlias( 158: query: string, 159: aliases?: string[], 160: ): string | undefined { 161: if (!aliases || aliases.length === 0 || query === '') { 162: return undefined 163: } 164: // Check if query is a prefix of any alias (case-insensitive) 165: return aliases.find(alias => alias.toLowerCase().startsWith(query)) 166: } 167: /** 168: * Creates a suggestion item from a command. 169: * Only shows the matched alias in parentheses if the user typed an alias. 170: */ 171: function createCommandSuggestionItem( 172: cmd: Command, 173: matchedAlias?: string, 174: ): SuggestionItem { 175: const commandName = getCommandName(cmd) 176: // Only show the alias if the user typed it 177: const aliasText = matchedAlias ? ` (${matchedAlias})` : '' 178: const isWorkflow = cmd.type === 'prompt' && cmd.kind === 'workflow' 179: const fullDescription = 180: (isWorkflow ? cmd.description : formatDescriptionWithSource(cmd)) + 181: (cmd.type === 'prompt' && cmd.argNames?.length 182: ? ` (arguments: ${cmd.argNames.join(', ')})` 183: : '') 184: return { 185: id: getCommandId(cmd), 186: displayText: `/${commandName}${aliasText}`, 187: tag: isWorkflow ? 'workflow' : undefined, 188: description: fullDescription, 189: metadata: cmd, 190: } 191: } 192: export function generateCommandSuggestions( 193: input: string, 194: commands: Command[], 195: ): SuggestionItem[] { 196: if (!isCommandInput(input)) { 197: return [] 198: } 199: if (hasCommandArgs(input)) { 200: return [] 201: } 202: const query = input.slice(1).toLowerCase().trim() 203: if (query === '') { 204: const visibleCommands = commands.filter(cmd => !cmd.isHidden) 205: // Find recently used skills (only prompt commands have usage tracking) 206: const recentlyUsed: Command[] = [] 207: const commandsWithScores = visibleCommands 208: .filter(cmd => cmd.type === 'prompt') 209: .map(cmd => ({ 210: cmd, 211: score: getSkillUsageScore(getCommandName(cmd)), 212: })) 213: .filter(item => item.score > 0) 214: .sort((a, b) => b.score - a.score) 215: for (const item of commandsWithScores.slice(0, 5)) { 216: recentlyUsed.push(item.cmd) 217: } 218: const recentlyUsedIds = new Set(recentlyUsed.map(cmd => getCommandId(cmd))) 219: const builtinCommands: Command[] = [] 220: const userCommands: Command[] = [] 221: const projectCommands: Command[] = [] 222: const policyCommands: Command[] = [] 223: const otherCommands: Command[] = [] 224: visibleCommands.forEach(cmd => { 225: if (recentlyUsedIds.has(getCommandId(cmd))) { 226: return 227: } 228: if (cmd.type === 'local' || cmd.type === 'local-jsx') { 229: builtinCommands.push(cmd) 230: } else if ( 231: cmd.type === 'prompt' && 232: (cmd.source === 'userSettings' || cmd.source === 'localSettings') 233: ) { 234: userCommands.push(cmd) 235: } else if (cmd.type === 'prompt' && cmd.source === 'projectSettings') { 236: projectCommands.push(cmd) 237: } else if (cmd.type === 'prompt' && cmd.source === 'policySettings') { 238: policyCommands.push(cmd) 239: } else { 240: otherCommands.push(cmd) 241: } 242: }) 243: const sortAlphabetically = (a: Command, b: Command) => 244: getCommandName(a).localeCompare(getCommandName(b)) 245: builtinCommands.sort(sortAlphabetically) 246: userCommands.sort(sortAlphabetically) 247: projectCommands.sort(sortAlphabetically) 248: policyCommands.sort(sortAlphabetically) 249: otherCommands.sort(sortAlphabetically) 250: return [ 251: ...recentlyUsed, 252: ...builtinCommands, 253: ...userCommands, 254: ...projectCommands, 255: ...policyCommands, 256: ...otherCommands, 257: ].map(cmd => createCommandSuggestionItem(cmd)) 258: } 259: let hiddenExact = commands.find( 260: cmd => cmd.isHidden && getCommandName(cmd).toLowerCase() === query, 261: ) 262: if ( 263: hiddenExact && 264: commands.some( 265: cmd => !cmd.isHidden && getCommandName(cmd).toLowerCase() === query, 266: ) 267: ) { 268: hiddenExact = undefined 269: } 270: const fuse = getCommandFuse(commands) 271: const searchResults = fuse.search(query) 272: const withMeta = searchResults.map(r => { 273: const name = r.item.commandName.toLowerCase() 274: const aliases = r.item.aliasKey?.map(alias => alias.toLowerCase()) ?? [] 275: const usage = 276: r.item.command.type === 'prompt' 277: ? getSkillUsageScore(getCommandName(r.item.command)) 278: : 0 279: return { r, name, aliases, usage } 280: }) 281: const sortedResults = withMeta.sort((a, b) => { 282: const aName = a.name 283: const bName = b.name 284: const aAliases = a.aliases 285: const bAliases = b.aliases 286: const aExactName = aName === query 287: const bExactName = bName === query 288: if (aExactName && !bExactName) return -1 289: if (bExactName && !aExactName) return 1 290: const aExactAlias = aAliases.some(alias => alias === query) 291: const bExactAlias = bAliases.some(alias => alias === query) 292: if (aExactAlias && !bExactAlias) return -1 293: if (bExactAlias && !aExactAlias) return 1 294: const aPrefixName = aName.startsWith(query) 295: const bPrefixName = bName.startsWith(query) 296: if (aPrefixName && !bPrefixName) return -1 297: if (bPrefixName && !aPrefixName) return 1 298: if (aPrefixName && bPrefixName && aName.length !== bName.length) { 299: return aName.length - bName.length 300: } 301: const aPrefixAlias = aAliases.find(alias => alias.startsWith(query)) 302: const bPrefixAlias = bAliases.find(alias => alias.startsWith(query)) 303: if (aPrefixAlias && !bPrefixAlias) return -1 304: if (bPrefixAlias && !aPrefixAlias) return 1 305: if ( 306: aPrefixAlias && 307: bPrefixAlias && 308: aPrefixAlias.length !== bPrefixAlias.length 309: ) { 310: return aPrefixAlias.length - bPrefixAlias.length 311: } 312: const scoreDiff = (a.r.score ?? 0) - (b.r.score ?? 0) 313: if (Math.abs(scoreDiff) > 0.1) { 314: return scoreDiff 315: } 316: return b.usage - a.usage 317: }) 318: const fuseSuggestions = sortedResults.map(result => { 319: const cmd = result.r.item.command 320: const matchedAlias = findMatchedAlias(query, cmd.aliases) 321: return createCommandSuggestionItem(cmd, matchedAlias) 322: }) 323: if (hiddenExact) { 324: const hiddenId = getCommandId(hiddenExact) 325: if (!fuseSuggestions.some(s => s.id === hiddenId)) { 326: return [createCommandSuggestionItem(hiddenExact), ...fuseSuggestions] 327: } 328: } 329: return fuseSuggestions 330: } 331: export function applyCommandSuggestion( 332: suggestion: string | SuggestionItem, 333: shouldExecute: boolean, 334: commands: Command[], 335: onInputChange: (value: string) => void, 336: setCursorOffset: (offset: number) => void, 337: onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void, 338: ): void { 339: let commandName: string 340: let commandObj: Command | undefined 341: if (typeof suggestion === 'string') { 342: commandName = suggestion 343: commandObj = shouldExecute ? getCommand(commandName, commands) : undefined 344: } else { 345: if (!isCommandMetadata(suggestion.metadata)) { 346: return 347: } 348: commandName = getCommandName(suggestion.metadata) 349: commandObj = suggestion.metadata 350: } 351: const newInput = formatCommand(commandName) 352: onInputChange(newInput) 353: setCursorOffset(newInput.length) 354: if (shouldExecute && commandObj) { 355: if ( 356: commandObj.type !== 'prompt' || 357: (commandObj.argNames ?? []).length === 0 358: ) { 359: onSubmit(newInput, true) 360: } 361: } 362: } 363: function cleanWord(word: string) { 364: return word.toLowerCase().replace(/[^a-z0-9]/g, '') 365: } 366: /** 367: * Find all /command patterns in text for highlighting. 368: * Returns array of {start, end} positions. 369: * Requires whitespace or start-of-string before the slash to avoid 370: * matching paths like /usr/bin. 371: */ 372: export function findSlashCommandPositions( 373: text: string, 374: ): Array<{ start: number; end: number }> { 375: const positions: Array<{ start: number; end: number }> = [] 376: // Match /command patterns preceded by whitespace or start-of-string 377: const regex = /(^|[\s])(\/[a-zA-Z][a-zA-Z0-9:\-_]*)/g 378: let match: RegExpExecArray | null = null 379: while ((match = regex.exec(text)) !== null) { 380: const precedingChar = match[1] ?? '' 381: const commandName = match[2] ?? '' 382: const start = match.index + precedingChar.length 383: positions.push({ start, end: start + commandName.length }) 384: } 385: return positions 386: }

File: src/utils/suggestions/directoryCompletion.ts

typescript 1: import { LRUCache } from 'lru-cache' 2: import { basename, dirname, join, sep } from 'path' 3: import type { SuggestionItem } from 'src/components/PromptInput/PromptInputFooterSuggestions.js' 4: import { getCwd } from 'src/utils/cwd.js' 5: import { getFsImplementation } from 'src/utils/fsOperations.js' 6: import { logError } from 'src/utils/log.js' 7: import { expandPath } from 'src/utils/path.js' 8: export type DirectoryEntry = { 9: name: string 10: path: string 11: type: 'directory' 12: } 13: export type PathEntry = { 14: name: string 15: path: string 16: type: 'directory' | 'file' 17: } 18: export type CompletionOptions = { 19: basePath?: string 20: maxResults?: number 21: } 22: export type PathCompletionOptions = CompletionOptions & { 23: includeFiles?: boolean 24: includeHidden?: boolean 25: } 26: type ParsedPath = { 27: directory: string 28: prefix: string 29: } 30: const CACHE_SIZE = 500 31: const CACHE_TTL = 5 * 60 * 1000 32: const directoryCache = new LRUCache<string, DirectoryEntry[]>({ 33: max: CACHE_SIZE, 34: ttl: CACHE_TTL, 35: }) 36: const pathCache = new LRUCache<string, PathEntry[]>({ 37: max: CACHE_SIZE, 38: ttl: CACHE_TTL, 39: }) 40: export function parsePartialPath( 41: partialPath: string, 42: basePath?: string, 43: ): ParsedPath { 44: if (!partialPath) { 45: const directory = basePath || getCwd() 46: return { directory, prefix: '' } 47: } 48: const resolved = expandPath(partialPath, basePath) 49: // If path ends with separator, treat as directory with no prefix 50: // Handle both forward slash and platform-specific separator 51: if (partialPath.endsWith('/') || partialPath.endsWith(sep)) { 52: return { directory: resolved, prefix: '' } 53: } 54: // Split into directory and prefix 55: const directory = dirname(resolved) 56: const prefix = basename(partialPath) 57: return { directory, prefix } 58: } 59: /** 60: * Scans a directory and returns subdirectories 61: * Uses LRU cache to avoid repeated filesystem calls 62: */ 63: export async function scanDirectory( 64: dirPath: string, 65: ): Promise<DirectoryEntry[]> { 66: // Check cache first 67: const cached = directoryCache.get(dirPath) 68: if (cached) { 69: return cached 70: } 71: try { 72: // Read directory contents 73: const fs = getFsImplementation() 74: const entries = await fs.readdir(dirPath) 75: // Filter for directories only, exclude hidden directories 76: const directories = entries 77: .filter(entry => entry.isDirectory() && !entry.name.startsWith('.')) 78: .map(entry => ({ 79: name: entry.name, 80: path: join(dirPath, entry.name), 81: type: 'directory' as const, 82: })) 83: .slice(0, 100) 84: directoryCache.set(dirPath, directories) 85: return directories 86: } catch (error) { 87: logError(error) 88: return [] 89: } 90: } 91: export async function getDirectoryCompletions( 92: partialPath: string, 93: options: CompletionOptions = {}, 94: ): Promise<SuggestionItem[]> { 95: const { basePath = getCwd(), maxResults = 10 } = options 96: const { directory, prefix } = parsePartialPath(partialPath, basePath) 97: const entries = await scanDirectory(directory) 98: const prefixLower = prefix.toLowerCase() 99: const matches = entries 100: .filter(entry => entry.name.toLowerCase().startsWith(prefixLower)) 101: .slice(0, maxResults) 102: return matches.map(entry => ({ 103: id: entry.path, 104: displayText: entry.name + '/', 105: description: 'directory', 106: metadata: { type: 'directory' as const }, 107: })) 108: } 109: export function clearDirectoryCache(): void { 110: directoryCache.clear() 111: } 112: export function isPathLikeToken(token: string): boolean { 113: return ( 114: token.startsWith('~/') || 115: token.startsWith('/') || 116: token.startsWith('./') || 117: token.startsWith('../') || 118: token === '~' || 119: token === '.' || 120: token === '..' 121: ) 122: } 123: export async function scanDirectoryForPaths( 124: dirPath: string, 125: includeHidden = false, 126: ): Promise<PathEntry[]> { 127: const cacheKey = `${dirPath}:${includeHidden}` 128: const cached = pathCache.get(cacheKey) 129: if (cached) { 130: return cached 131: } 132: try { 133: const fs = getFsImplementation() 134: const entries = await fs.readdir(dirPath) 135: const paths = entries 136: .filter(entry => includeHidden || !entry.name.startsWith('.')) 137: .map(entry => ({ 138: name: entry.name, 139: path: join(dirPath, entry.name), 140: type: entry.isDirectory() ? ('directory' as const) : ('file' as const), 141: })) 142: .sort((a, b) => { 143: if (a.type === 'directory' && b.type !== 'directory') return -1 144: if (a.type !== 'directory' && b.type === 'directory') return 1 145: return a.name.localeCompare(b.name) 146: }) 147: .slice(0, 100) 148: pathCache.set(cacheKey, paths) 149: return paths 150: } catch (error) { 151: logError(error) 152: return [] 153: } 154: } 155: export async function getPathCompletions( 156: partialPath: string, 157: options: PathCompletionOptions = {}, 158: ): Promise<SuggestionItem[]> { 159: const { 160: basePath = getCwd(), 161: maxResults = 10, 162: includeFiles = true, 163: includeHidden = false, 164: } = options 165: const { directory, prefix } = parsePartialPath(partialPath, basePath) 166: const entries = await scanDirectoryForPaths(directory, includeHidden) 167: const prefixLower = prefix.toLowerCase() 168: const matches = entries 169: .filter(entry => { 170: if (!includeFiles && entry.type === 'file') return false 171: return entry.name.toLowerCase().startsWith(prefixLower) 172: }) 173: .slice(0, maxResults) 174: const hasSeparator = partialPath.includes('/') || partialPath.includes(sep) 175: let dirPortion = '' 176: if (hasSeparator) { 177: // Find the last separator (either / or platform-specific) 178: const lastSlash = partialPath.lastIndexOf('/') 179: const lastSep = partialPath.lastIndexOf(sep) 180: const lastSeparatorPos = Math.max(lastSlash, lastSep) 181: dirPortion = partialPath.substring(0, lastSeparatorPos + 1) 182: } 183: if (dirPortion.startsWith('./') || dirPortion.startsWith('.' + sep)) { 184: dirPortion = dirPortion.slice(2) 185: } 186: return matches.map(entry => { 187: const fullPath = dirPortion + entry.name 188: return { 189: id: fullPath, 190: displayText: entry.type === 'directory' ? fullPath + '/' : fullPath, 191: metadata: { type: entry.type }, 192: } 193: }) 194: } 195: export function clearPathCache(): void { 196: directoryCache.clear() 197: pathCache.clear() 198: }

File: src/utils/suggestions/shellHistoryCompletion.ts

typescript 1: import { getHistory } from '../../history.js' 2: import { logForDebugging } from '../debug.js' 3: export type ShellHistoryMatch = { 4: fullCommand: string 5: suffix: string 6: } 7: let shellHistoryCache: string[] | null = null 8: let shellHistoryCacheTimestamp = 0 9: const CACHE_TTL_MS = 60000 10: async function getShellHistoryCommands(): Promise<string[]> { 11: const now = Date.now() 12: if (shellHistoryCache && now - shellHistoryCacheTimestamp < CACHE_TTL_MS) { 13: return shellHistoryCache 14: } 15: const commands: string[] = [] 16: const seen = new Set<string>() 17: try { 18: for await (const entry of getHistory()) { 19: if (entry.display && entry.display.startsWith('!')) { 20: const command = entry.display.slice(1).trim() 21: if (command && !seen.has(command)) { 22: seen.add(command) 23: commands.push(command) 24: } 25: } 26: if (commands.length >= 50) { 27: break 28: } 29: } 30: } catch (error) { 31: logForDebugging(`Failed to read shell history: ${error}`) 32: } 33: shellHistoryCache = commands 34: shellHistoryCacheTimestamp = now 35: return commands 36: } 37: export function clearShellHistoryCache(): void { 38: shellHistoryCache = null 39: shellHistoryCacheTimestamp = 0 40: } 41: export function prependToShellHistoryCache(command: string): void { 42: if (!shellHistoryCache) { 43: return 44: } 45: const idx = shellHistoryCache.indexOf(command) 46: if (idx !== -1) { 47: shellHistoryCache.splice(idx, 1) 48: } 49: shellHistoryCache.unshift(command) 50: } 51: export async function getShellHistoryCompletion( 52: input: string, 53: ): Promise<ShellHistoryMatch | null> { 54: if (!input || input.length < 2) { 55: return null 56: } 57: const trimmedInput = input.trim() 58: if (!trimmedInput) { 59: return null 60: } 61: const commands = await getShellHistoryCommands() 62: for (const command of commands) { 63: if (command.startsWith(input) && command !== input) { 64: return { 65: fullCommand: command, 66: suffix: command.slice(input.length), 67: } 68: } 69: } 70: return null 71: }

File: src/utils/suggestions/skillUsageTracking.ts

typescript 1: import { getGlobalConfig, saveGlobalConfig } from '../config.js' 2: const SKILL_USAGE_DEBOUNCE_MS = 60_000 3: const lastWriteBySkill = new Map<string, number>() 4: export function recordSkillUsage(skillName: string): void { 5: const now = Date.now() 6: const lastWrite = lastWriteBySkill.get(skillName) 7: if (lastWrite !== undefined && now - lastWrite < SKILL_USAGE_DEBOUNCE_MS) { 8: return 9: } 10: lastWriteBySkill.set(skillName, now) 11: saveGlobalConfig(current => { 12: const existing = current.skillUsage?.[skillName] 13: return { 14: ...current, 15: skillUsage: { 16: ...current.skillUsage, 17: [skillName]: { 18: usageCount: (existing?.usageCount ?? 0) + 1, 19: lastUsedAt: now, 20: }, 21: }, 22: } 23: }) 24: } 25: export function getSkillUsageScore(skillName: string): number { 26: const config = getGlobalConfig() 27: const usage = config.skillUsage?.[skillName] 28: if (!usage) return 0 29: const daysSinceUse = (Date.now() - usage.lastUsedAt) / (1000 * 60 * 60 * 24) 30: const recencyFactor = Math.pow(0.5, daysSinceUse / 7) 31: return usage.usageCount * Math.max(recencyFactor, 0.1) 32: }

File: src/utils/suggestions/slackChannelSuggestions.ts

typescript 1: import { z } from 'zod' 2: import type { SuggestionItem } from '../../components/PromptInput/PromptInputFooterSuggestions.js' 3: import type { MCPServerConnection } from '../../services/mcp/types.js' 4: import { logForDebugging } from '../debug.js' 5: import { lazySchema } from '../lazySchema.js' 6: import { createSignal } from '../signal.js' 7: import { jsonParse } from '../slowOperations.js' 8: const SLACK_SEARCH_TOOL = 'slack_search_channels' 9: const cache = new Map<string, string[]>() 10: const knownChannels = new Set<string>() 11: let knownChannelsVersion = 0 12: const knownChannelsChanged = createSignal() 13: export const subscribeKnownChannels = knownChannelsChanged.subscribe 14: let inflightQuery: string | null = null 15: let inflightPromise: Promise<string[]> | null = null 16: function findSlackClient( 17: clients: MCPServerConnection[], 18: ): MCPServerConnection | undefined { 19: return clients.find(c => c.type === 'connected' && c.name.includes('slack')) 20: } 21: async function fetchChannels( 22: clients: MCPServerConnection[], 23: query: string, 24: ): Promise<string[]> { 25: const slackClient = findSlackClient(clients) 26: if (!slackClient || slackClient.type !== 'connected') { 27: return [] 28: } 29: try { 30: const result = await slackClient.client.callTool( 31: { 32: name: SLACK_SEARCH_TOOL, 33: arguments: { 34: query, 35: limit: 20, 36: channel_types: 'public_channel,private_channel', 37: }, 38: }, 39: undefined, 40: { timeout: 5000 }, 41: ) 42: const content = result.content 43: if (!Array.isArray(content)) return [] 44: const rawText = content 45: .filter((c): c is { type: 'text'; text: string } => c.type === 'text') 46: .map(c => c.text) 47: .join('\n') 48: return parseChannels(unwrapResults(rawText)) 49: } catch (error) { 50: logForDebugging(`Failed to fetch Slack channels: ${error}`) 51: return [] 52: } 53: } 54: const resultsEnvelopeSchema = lazySchema(() => 55: z.object({ results: z.string() }), 56: ) 57: function unwrapResults(text: string): string { 58: const trimmed = text.trim() 59: if (!trimmed.startsWith('{')) return text 60: try { 61: const parsed = resultsEnvelopeSchema().safeParse(jsonParse(trimmed)) 62: if (parsed.success) return parsed.data.results 63: } catch { 64: } 65: return text 66: } 67: function parseChannels(text: string): string[] { 68: const channels: string[] = [] 69: const seen = new Set<string>() 70: for (const line of text.split('\n')) { 71: const m = line.match(/^Name:\s*#?([a-z0-9][a-z0-9_-]{0,79})\s*$/) 72: if (m && !seen.has(m[1]!)) { 73: seen.add(m[1]!) 74: channels.push(m[1]!) 75: } 76: } 77: return channels 78: } 79: export function hasSlackMcpServer(clients: MCPServerConnection[]): boolean { 80: return findSlackClient(clients) !== undefined 81: } 82: export function getKnownChannelsVersion(): number { 83: return knownChannelsVersion 84: } 85: export function findSlackChannelPositions( 86: text: string, 87: ): Array<{ start: number; end: number }> { 88: const positions: Array<{ start: number; end: number }> = [] 89: const re = /(^|\s)#([a-z0-9][a-z0-9_-]{0,79})(?=\s|$)/g 90: let m: RegExpExecArray | null 91: while ((m = re.exec(text)) !== null) { 92: if (!knownChannels.has(m[2]!)) continue 93: const start = m.index + m[1]!.length 94: positions.push({ start, end: start + 1 + m[2]!.length }) 95: } 96: return positions 97: } 98: function mcpQueryFor(searchToken: string): string { 99: const lastSep = Math.max( 100: searchToken.lastIndexOf('-'), 101: searchToken.lastIndexOf('_'), 102: ) 103: return lastSep > 0 ? searchToken.slice(0, lastSep) : searchToken 104: } 105: function findReusableCacheEntry( 106: mcpQuery: string, 107: searchToken: string, 108: ): string[] | undefined { 109: let best: string[] | undefined 110: let bestLen = 0 111: for (const [key, channels] of cache) { 112: if ( 113: mcpQuery.startsWith(key) && 114: key.length > bestLen && 115: channels.some(c => c.startsWith(searchToken)) 116: ) { 117: best = channels 118: bestLen = key.length 119: } 120: } 121: return best 122: } 123: export async function getSlackChannelSuggestions( 124: clients: MCPServerConnection[], 125: searchToken: string, 126: ): Promise<SuggestionItem[]> { 127: if (!searchToken) return [] 128: const mcpQuery = mcpQueryFor(searchToken) 129: const lower = searchToken.toLowerCase() 130: let channels = cache.get(mcpQuery) ?? findReusableCacheEntry(mcpQuery, lower) 131: if (!channels) { 132: if (inflightQuery === mcpQuery && inflightPromise) { 133: channels = await inflightPromise 134: } else { 135: inflightQuery = mcpQuery 136: inflightPromise = fetchChannels(clients, mcpQuery) 137: channels = await inflightPromise 138: cache.set(mcpQuery, channels) 139: const before = knownChannels.size 140: for (const c of channels) knownChannels.add(c) 141: if (knownChannels.size !== before) { 142: knownChannelsVersion++ 143: knownChannelsChanged.emit() 144: } 145: if (cache.size > 50) { 146: cache.delete(cache.keys().next().value!) 147: } 148: if (inflightQuery === mcpQuery) { 149: inflightQuery = null 150: inflightPromise = null 151: } 152: } 153: } 154: return channels 155: .filter(c => c.startsWith(lower)) 156: .sort() 157: .slice(0, 10) 158: .map(c => ({ 159: id: `slack-channel-${c}`, 160: displayText: `#${c}`, 161: })) 162: } 163: export function clearSlackChannelCache(): void { 164: cache.clear() 165: knownChannels.clear() 166: knownChannelsVersion = 0 167: inflightQuery = null 168: inflightPromise = null 169: }

File: src/utils/swarm/backends/detection.ts

typescript 1: import { env } from '../../../utils/env.js' 2: import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' 3: import { TMUX_COMMAND } from '../constants.js' 4: const ORIGINAL_USER_TMUX = process.env.TMUX 5: const ORIGINAL_TMUX_PANE = process.env.TMUX_PANE 6: let isInsideTmuxCached: boolean | null = null 7: let isInITerm2Cached: boolean | null = null 8: export function isInsideTmuxSync(): boolean { 9: return !!ORIGINAL_USER_TMUX 10: } 11: export async function isInsideTmux(): Promise<boolean> { 12: if (isInsideTmuxCached !== null) { 13: return isInsideTmuxCached 14: } 15: isInsideTmuxCached = !!ORIGINAL_USER_TMUX 16: return isInsideTmuxCached 17: } 18: export function getLeaderPaneId(): string | null { 19: return ORIGINAL_TMUX_PANE || null 20: } 21: export async function isTmuxAvailable(): Promise<boolean> { 22: const result = await execFileNoThrow(TMUX_COMMAND, ['-V']) 23: return result.code === 0 24: } 25: export function isInITerm2(): boolean { 26: if (isInITerm2Cached !== null) { 27: return isInITerm2Cached 28: } 29: const termProgram = process.env.TERM_PROGRAM 30: const hasItermSessionId = !!process.env.ITERM_SESSION_ID 31: const terminalIsITerm = env.terminal === 'iTerm.app' 32: isInITerm2Cached = 33: termProgram === 'iTerm.app' || hasItermSessionId || terminalIsITerm 34: return isInITerm2Cached 35: } 36: export const IT2_COMMAND = 'it2' 37: export async function isIt2CliAvailable(): Promise<boolean> { 38: const result = await execFileNoThrow(IT2_COMMAND, ['session', 'list']) 39: return result.code === 0 40: } 41: export function resetDetectionCache(): void { 42: isInsideTmuxCached = null 43: isInITerm2Cached = null 44: }

File: src/utils/swarm/backends/InProcessBackend.ts

typescript 1: import type { ToolUseContext } from '../../../Tool.js' 2: import { 3: findTeammateTaskByAgentId, 4: requestTeammateShutdown, 5: } from '../../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' 6: import { parseAgentId } from '../../../utils/agentId.js' 7: import { logForDebugging } from '../../../utils/debug.js' 8: import { jsonStringify } from '../../../utils/slowOperations.js' 9: import { 10: createShutdownRequestMessage, 11: writeToMailbox, 12: } from '../../../utils/teammateMailbox.js' 13: import { startInProcessTeammate } from '../inProcessRunner.js' 14: import { 15: killInProcessTeammate, 16: spawnInProcessTeammate, 17: } from '../spawnInProcess.js' 18: import type { 19: TeammateExecutor, 20: TeammateMessage, 21: TeammateSpawnConfig, 22: TeammateSpawnResult, 23: } from './types.js' 24: export class InProcessBackend implements TeammateExecutor { 25: readonly type = 'in-process' as const 26: private context: ToolUseContext | null = null 27: setContext(context: ToolUseContext): void { 28: this.context = context 29: } 30: async isAvailable(): Promise<boolean> { 31: return true 32: } 33: async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> { 34: if (!this.context) { 35: logForDebugging( 36: `[InProcessBackend] spawn() called without context for ${config.name}`, 37: ) 38: return { 39: success: false, 40: agentId: `${config.name}@${config.teamName}`, 41: error: 42: 'InProcessBackend not initialized. Call setContext() before spawn().', 43: } 44: } 45: logForDebugging(`[InProcessBackend] spawn() called for ${config.name}`) 46: const result = await spawnInProcessTeammate( 47: { 48: name: config.name, 49: teamName: config.teamName, 50: prompt: config.prompt, 51: color: config.color, 52: planModeRequired: config.planModeRequired ?? false, 53: }, 54: this.context, 55: ) 56: if ( 57: result.success && 58: result.taskId && 59: result.teammateContext && 60: result.abortController 61: ) { 62: startInProcessTeammate({ 63: identity: { 64: agentId: result.agentId, 65: agentName: config.name, 66: teamName: config.teamName, 67: color: config.color, 68: planModeRequired: config.planModeRequired ?? false, 69: parentSessionId: result.teammateContext.parentSessionId, 70: }, 71: taskId: result.taskId, 72: prompt: config.prompt, 73: teammateContext: result.teammateContext, 74: toolUseContext: { ...this.context, messages: [] }, 75: abortController: result.abortController, 76: model: config.model, 77: systemPrompt: config.systemPrompt, 78: systemPromptMode: config.systemPromptMode, 79: allowedTools: config.permissions, 80: allowPermissionPrompts: config.allowPermissionPrompts, 81: }) 82: logForDebugging( 83: `[InProcessBackend] Started agent execution for ${result.agentId}`, 84: ) 85: } 86: return { 87: success: result.success, 88: agentId: result.agentId, 89: taskId: result.taskId, 90: abortController: result.abortController, 91: error: result.error, 92: } 93: } 94: async sendMessage(agentId: string, message: TeammateMessage): Promise<void> { 95: logForDebugging( 96: `[InProcessBackend] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`, 97: ) 98: const parsed = parseAgentId(agentId) 99: if (!parsed) { 100: logForDebugging(`[InProcessBackend] Invalid agentId format: ${agentId}`) 101: throw new Error( 102: `Invalid agentId format: ${agentId}. Expected format: agentName@teamName`, 103: ) 104: } 105: const { agentName, teamName } = parsed 106: await writeToMailbox( 107: agentName, 108: { 109: text: message.text, 110: from: message.from, 111: color: message.color, 112: timestamp: message.timestamp ?? new Date().toISOString(), 113: }, 114: teamName, 115: ) 116: logForDebugging(`[InProcessBackend] sendMessage() completed for ${agentId}`) 117: } 118: async terminate(agentId: string, reason?: string): Promise<boolean> { 119: logForDebugging( 120: `[InProcessBackend] terminate() called for ${agentId}: ${reason}`, 121: ) 122: if (!this.context) { 123: logForDebugging( 124: `[InProcessBackend] terminate() failed: no context set for ${agentId}`, 125: ) 126: return false 127: } 128: const state = this.context.getAppState() 129: const task = findTeammateTaskByAgentId(agentId, state.tasks) 130: if (!task) { 131: logForDebugging( 132: `[InProcessBackend] terminate() failed: task not found for ${agentId}`, 133: ) 134: return false 135: } 136: if (task.shutdownRequested) { 137: logForDebugging( 138: `[InProcessBackend] terminate(): shutdown already requested for ${agentId}`, 139: ) 140: return true 141: } 142: const requestId = `shutdown-${agentId}-${Date.now()}` 143: const shutdownRequest = createShutdownRequestMessage({ 144: requestId, 145: from: 'team-lead', 146: reason, 147: }) 148: const teammateAgentName = task.identity.agentName 149: await writeToMailbox( 150: teammateAgentName, 151: { 152: from: 'team-lead', 153: text: jsonStringify(shutdownRequest), 154: timestamp: new Date().toISOString(), 155: }, 156: task.identity.teamName, 157: ) 158: requestTeammateShutdown(task.id, this.context.setAppState) 159: logForDebugging( 160: `[InProcessBackend] terminate() sent shutdown request to ${agentId}`, 161: ) 162: return true 163: } 164: async kill(agentId: string): Promise<boolean> { 165: logForDebugging(`[InProcessBackend] kill() called for ${agentId}`) 166: if (!this.context) { 167: logForDebugging( 168: `[InProcessBackend] kill() failed: no context set for ${agentId}`, 169: ) 170: return false 171: } 172: const state = this.context.getAppState() 173: const task = findTeammateTaskByAgentId(agentId, state.tasks) 174: if (!task) { 175: logForDebugging( 176: `[InProcessBackend] kill() failed: task not found for ${agentId}`, 177: ) 178: return false 179: } 180: const killed = killInProcessTeammate(task.id, this.context.setAppState) 181: logForDebugging( 182: `[InProcessBackend] kill() ${killed ? 'succeeded' : 'failed'} for ${agentId}`, 183: ) 184: return killed 185: } 186: async isActive(agentId: string): Promise<boolean> { 187: logForDebugging(`[InProcessBackend] isActive() called for ${agentId}`) 188: if (!this.context) { 189: logForDebugging( 190: `[InProcessBackend] isActive() failed: no context set for ${agentId}`, 191: ) 192: return false 193: } 194: const state = this.context.getAppState() 195: const task = findTeammateTaskByAgentId(agentId, state.tasks) 196: if (!task) { 197: logForDebugging( 198: `[InProcessBackend] isActive(): task not found for ${agentId}`, 199: ) 200: return false 201: } 202: const isRunning = task.status === 'running' 203: const isAborted = task.abortController?.signal.aborted ?? true 204: const active = isRunning && !isAborted 205: logForDebugging( 206: `[InProcessBackend] isActive() for ${agentId}: ${active} (running=${isRunning}, aborted=${isAborted})`, 207: ) 208: return active 209: } 210: } 211: export function createInProcessBackend(): InProcessBackend { 212: return new InProcessBackend() 213: }

File: src/utils/swarm/backends/it2Setup.ts

typescript 1: import { homedir } from 'os' 2: import { getGlobalConfig, saveGlobalConfig } from '../../../utils/config.js' 3: import { logForDebugging } from '../../../utils/debug.js' 4: import { 5: execFileNoThrow, 6: execFileNoThrowWithCwd, 7: } from '../../../utils/execFileNoThrow.js' 8: import { logError } from '../../../utils/log.js' 9: export type PythonPackageManager = 'uvx' | 'pipx' | 'pip' 10: export type It2InstallResult = { 11: success: boolean 12: error?: string 13: packageManager?: PythonPackageManager 14: } 15: export type It2VerifyResult = { 16: success: boolean 17: error?: string 18: needsPythonApiEnabled?: boolean 19: } 20: export async function detectPythonPackageManager(): Promise<PythonPackageManager | null> { 21: const uvResult = await execFileNoThrow('which', ['uv']) 22: if (uvResult.code === 0) { 23: logForDebugging('[it2Setup] Found uv (will use uv tool install)') 24: return 'uvx' 25: } 26: const pipxResult = await execFileNoThrow('which', ['pipx']) 27: if (pipxResult.code === 0) { 28: logForDebugging('[it2Setup] Found pipx package manager') 29: return 'pipx' 30: } 31: const pipResult = await execFileNoThrow('which', ['pip']) 32: if (pipResult.code === 0) { 33: logForDebugging('[it2Setup] Found pip package manager') 34: return 'pip' 35: } 36: const pip3Result = await execFileNoThrow('which', ['pip3']) 37: if (pip3Result.code === 0) { 38: logForDebugging('[it2Setup] Found pip3 package manager') 39: return 'pip' 40: } 41: logForDebugging('[it2Setup] No Python package manager found') 42: return null 43: } 44: export async function isIt2CliAvailable(): Promise<boolean> { 45: const result = await execFileNoThrow('which', ['it2']) 46: return result.code === 0 47: } 48: export async function installIt2( 49: packageManager: PythonPackageManager, 50: ): Promise<It2InstallResult> { 51: logForDebugging(`[it2Setup] Installing it2 using ${packageManager}`) 52: let result 53: switch (packageManager) { 54: case 'uvx': 55: result = await execFileNoThrowWithCwd('uv', ['tool', 'install', 'it2'], { 56: cwd: homedir(), 57: }) 58: break 59: case 'pipx': 60: result = await execFileNoThrowWithCwd('pipx', ['install', 'it2'], { 61: cwd: homedir(), 62: }) 63: break 64: case 'pip': 65: result = await execFileNoThrowWithCwd( 66: 'pip', 67: ['install', '--user', 'it2'], 68: { cwd: homedir() }, 69: ) 70: if (result.code !== 0) { 71: result = await execFileNoThrowWithCwd( 72: 'pip3', 73: ['install', '--user', 'it2'], 74: { cwd: homedir() }, 75: ) 76: } 77: break 78: } 79: if (result.code !== 0) { 80: const error = result.stderr || 'Unknown installation error' 81: logError(new Error(`[it2Setup] Failed to install it2: ${error}`)) 82: return { 83: success: false, 84: error, 85: packageManager, 86: } 87: } 88: logForDebugging('[it2Setup] it2 installed successfully') 89: return { 90: success: true, 91: packageManager, 92: } 93: } 94: export async function verifyIt2Setup(): Promise<It2VerifyResult> { 95: logForDebugging('[it2Setup] Verifying it2 setup...') 96: const installed = await isIt2CliAvailable() 97: if (!installed) { 98: return { 99: success: false, 100: error: 'it2 CLI is not installed or not in PATH', 101: } 102: } 103: const result = await execFileNoThrow('it2', ['session', 'list']) 104: if (result.code !== 0) { 105: const stderr = result.stderr.toLowerCase() 106: if ( 107: stderr.includes('api') || 108: stderr.includes('python') || 109: stderr.includes('connection refused') || 110: stderr.includes('not enabled') 111: ) { 112: logForDebugging('[it2Setup] Python API not enabled in iTerm2') 113: return { 114: success: false, 115: error: 'Python API not enabled in iTerm2 preferences', 116: needsPythonApiEnabled: true, 117: } 118: } 119: return { 120: success: false, 121: error: result.stderr || 'Failed to communicate with iTerm2', 122: } 123: } 124: logForDebugging('[it2Setup] it2 setup verified successfully') 125: return { 126: success: true, 127: } 128: } 129: export function getPythonApiInstructions(): string[] { 130: return [ 131: 'Almost done! Enable the Python API in iTerm2:', 132: '', 133: ' iTerm2 → Settings → General → Magic → Enable Python API', 134: '', 135: 'After enabling, you may need to restart iTerm2.', 136: ] 137: } 138: /** 139: * Marks that it2 setup has been completed successfully. 140: * This prevents showing the setup prompt again. 141: */ 142: export function markIt2SetupComplete(): void { 143: const config = getGlobalConfig() 144: if (config.iterm2It2SetupComplete !== true) { 145: saveGlobalConfig(current => ({ 146: ...current, 147: iterm2It2SetupComplete: true, 148: })) 149: logForDebugging('[it2Setup] Marked it2 setup as complete') 150: } 151: } 152: export function setPreferTmuxOverIterm2(prefer: boolean): void { 153: const config = getGlobalConfig() 154: if (config.preferTmuxOverIterm2 !== prefer) { 155: saveGlobalConfig(current => ({ 156: ...current, 157: preferTmuxOverIterm2: prefer, 158: })) 159: logForDebugging(`[it2Setup] Set preferTmuxOverIterm2 = ${prefer}`) 160: } 161: } 162: export function getPreferTmuxOverIterm2(): boolean { 163: return getGlobalConfig().preferTmuxOverIterm2 === true 164: }

File: src/utils/swarm/backends/ITermBackend.ts

typescript 1: import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' 2: import { logForDebugging } from '../../../utils/debug.js' 3: import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' 4: import { IT2_COMMAND, isInITerm2, isIt2CliAvailable } from './detection.js' 5: import { registerITermBackend } from './registry.js' 6: import type { CreatePaneResult, PaneBackend, PaneId } from './types.js' 7: const teammateSessionIds: string[] = [] 8: let firstPaneUsed = false 9: let paneCreationLock: Promise<void> = Promise.resolve() 10: function acquirePaneCreationLock(): Promise<() => void> { 11: let release: () => void 12: const newLock = new Promise<void>(resolve => { 13: release = resolve 14: }) 15: const previousLock = paneCreationLock 16: paneCreationLock = newLock 17: return previousLock.then(() => release!) 18: } 19: function runIt2( 20: args: string[], 21: ): Promise<{ stdout: string; stderr: string; code: number }> { 22: return execFileNoThrow(IT2_COMMAND, args) 23: } 24: function parseSplitOutput(output: string): string { 25: const match = output.match(/Created new pane:\s*(.+)/) 26: if (match && match[1]) { 27: return match[1].trim() 28: } 29: return '' 30: } 31: /** 32: * Gets the leader's session ID from ITERM_SESSION_ID env var. 33: * Format: "wXtYpZ:UUID" - we extract the UUID part after the colon. 34: * Returns null if not in iTerm2 or env var not set. 35: */ 36: function getLeaderSessionId(): string | null { 37: const itermSessionId = process.env.ITERM_SESSION_ID 38: if (!itermSessionId) { 39: return null 40: } 41: const colonIndex = itermSessionId.indexOf(':') 42: if (colonIndex === -1) { 43: return null 44: } 45: return itermSessionId.slice(colonIndex + 1) 46: } 47: export class ITermBackend implements PaneBackend { 48: readonly type = 'iterm2' as const 49: readonly displayName = 'iTerm2' 50: readonly supportsHideShow = false 51: async isAvailable(): Promise<boolean> { 52: const inITerm2 = isInITerm2() 53: logForDebugging(`[ITermBackend] isAvailable check: inITerm2=${inITerm2}`) 54: if (!inITerm2) { 55: logForDebugging('[ITermBackend] isAvailable: false (not in iTerm2)') 56: return false 57: } 58: const it2Available = await isIt2CliAvailable() 59: logForDebugging( 60: `[ITermBackend] isAvailable: ${it2Available} (it2 CLI ${it2Available ? 'found' : 'not found'})`, 61: ) 62: return it2Available 63: } 64: async isRunningInside(): Promise<boolean> { 65: const result = isInITerm2() 66: logForDebugging(`[ITermBackend] isRunningInside: ${result}`) 67: return result 68: } 69: async createTeammatePaneInSwarmView( 70: name: string, 71: color: AgentColorName, 72: ): Promise<CreatePaneResult> { 73: logForDebugging( 74: `[ITermBackend] createTeammatePaneInSwarmView called for ${name} with color ${color}`, 75: ) 76: const releaseLock = await acquirePaneCreationLock() 77: try { 78: while (true) { 79: const isFirstTeammate = !firstPaneUsed 80: logForDebugging( 81: `[ITermBackend] Creating pane: isFirstTeammate=${isFirstTeammate}, existingPanes=${teammateSessionIds.length}`, 82: ) 83: let splitArgs: string[] 84: let targetedTeammateId: string | undefined 85: if (isFirstTeammate) { 86: const leaderSessionId = getLeaderSessionId() 87: if (leaderSessionId) { 88: splitArgs = ['session', 'split', '-v', '-s', leaderSessionId] 89: logForDebugging( 90: `[ITermBackend] First split from leader session: ${leaderSessionId}`, 91: ) 92: } else { 93: splitArgs = ['session', 'split', '-v'] 94: logForDebugging( 95: '[ITermBackend] First split from active session (no leader ID)', 96: ) 97: } 98: } else { 99: targetedTeammateId = teammateSessionIds[teammateSessionIds.length - 1] 100: if (targetedTeammateId) { 101: splitArgs = ['session', 'split', '-s', targetedTeammateId] 102: logForDebugging( 103: `[ITermBackend] Subsequent split from teammate session: ${targetedTeammateId}`, 104: ) 105: } else { 106: splitArgs = ['session', 'split'] 107: logForDebugging( 108: '[ITermBackend] Subsequent split from active session (no teammate ID)', 109: ) 110: } 111: } 112: const splitResult = await runIt2(splitArgs) 113: if (splitResult.code !== 0) { 114: if (targetedTeammateId) { 115: const listResult = await runIt2(['session', 'list']) 116: if ( 117: listResult.code === 0 && 118: !listResult.stdout.includes(targetedTeammateId) 119: ) { 120: logForDebugging( 121: `[ITermBackend] Split failed targeting dead session ${targetedTeammateId}, pruning and retrying: ${splitResult.stderr}`, 122: ) 123: const idx = teammateSessionIds.indexOf(targetedTeammateId) 124: if (idx !== -1) { 125: teammateSessionIds.splice(idx, 1) 126: } 127: if (teammateSessionIds.length === 0) { 128: firstPaneUsed = false 129: } 130: continue 131: } 132: } 133: throw new Error( 134: `Failed to create iTerm2 split pane: ${splitResult.stderr}`, 135: ) 136: } 137: if (isFirstTeammate) { 138: firstPaneUsed = true 139: } 140: const paneId = parseSplitOutput(splitResult.stdout) 141: if (!paneId) { 142: throw new Error( 143: `Failed to parse session ID from split output: ${splitResult.stdout}`, 144: ) 145: } 146: logForDebugging( 147: `[ITermBackend] Created teammate pane for ${name}: ${paneId}`, 148: ) 149: teammateSessionIds.push(paneId) 150: return { paneId, isFirstTeammate } 151: } 152: } finally { 153: releaseLock() 154: } 155: } 156: async sendCommandToPane( 157: paneId: PaneId, 158: command: string, 159: _useExternalSession?: boolean, 160: ): Promise<void> { 161: const args = paneId 162: ? ['session', 'run', '-s', paneId, command] 163: : ['session', 'run', command] 164: const result = await runIt2(args) 165: if (result.code !== 0) { 166: throw new Error( 167: `Failed to send command to iTerm2 pane ${paneId}: ${result.stderr}`, 168: ) 169: } 170: } 171: async setPaneBorderColor( 172: _paneId: PaneId, 173: _color: AgentColorName, 174: _useExternalSession?: boolean, 175: ): Promise<void> { 176: } 177: async setPaneTitle( 178: _paneId: PaneId, 179: _name: string, 180: _color: AgentColorName, 181: _useExternalSession?: boolean, 182: ): Promise<void> { 183: } 184: async enablePaneBorderStatus( 185: _windowTarget?: string, 186: _useExternalSession?: boolean, 187: ): Promise<void> { 188: } 189: async rebalancePanes( 190: _windowTarget: string, 191: _hasLeader: boolean, 192: ): Promise<void> { 193: logForDebugging( 194: '[ITermBackend] Pane rebalancing not implemented for iTerm2', 195: ) 196: } 197: async killPane( 198: paneId: PaneId, 199: _useExternalSession?: boolean, 200: ): Promise<boolean> { 201: const result = await runIt2(['session', 'close', '-f', '-s', paneId]) 202: const idx = teammateSessionIds.indexOf(paneId) 203: if (idx !== -1) { 204: teammateSessionIds.splice(idx, 1) 205: } 206: if (teammateSessionIds.length === 0) { 207: firstPaneUsed = false 208: } 209: return result.code === 0 210: } 211: async hidePane( 212: _paneId: PaneId, 213: _useExternalSession?: boolean, 214: ): Promise<boolean> { 215: logForDebugging('[ITermBackend] hidePane not supported in iTerm2') 216: return false 217: } 218: async showPane( 219: _paneId: PaneId, 220: _targetWindowOrPane: string, 221: _useExternalSession?: boolean, 222: ): Promise<boolean> { 223: logForDebugging('[ITermBackend] showPane not supported in iTerm2') 224: return false 225: } 226: } 227: registerITermBackend(ITermBackend)

File: src/utils/swarm/backends/PaneBackendExecutor.ts

typescript 1: import { getSessionId } from '../../../bootstrap/state.js' 2: import type { ToolUseContext } from '../../../Tool.js' 3: import { formatAgentId, parseAgentId } from '../../../utils/agentId.js' 4: import { quote } from '../../../utils/bash/shellQuote.js' 5: import { registerCleanup } from '../../../utils/cleanupRegistry.js' 6: import { logForDebugging } from '../../../utils/debug.js' 7: import { jsonStringify } from '../../../utils/slowOperations.js' 8: import { writeToMailbox } from '../../../utils/teammateMailbox.js' 9: import { 10: buildInheritedCliFlags, 11: buildInheritedEnvVars, 12: getTeammateCommand, 13: } from '../spawnUtils.js' 14: import { assignTeammateColor } from '../teammateLayoutManager.js' 15: import { isInsideTmux } from './detection.js' 16: import type { 17: BackendType, 18: PaneBackend, 19: TeammateExecutor, 20: TeammateMessage, 21: TeammateSpawnConfig, 22: TeammateSpawnResult, 23: } from './types.js' 24: export class PaneBackendExecutor implements TeammateExecutor { 25: readonly type: BackendType 26: private backend: PaneBackend 27: private context: ToolUseContext | null = null 28: private spawnedTeammates: Map<string, { paneId: string; insideTmux: boolean }> 29: private cleanupRegistered = false 30: constructor(backend: PaneBackend) { 31: this.backend = backend 32: this.type = backend.type 33: this.spawnedTeammates = new Map() 34: } 35: setContext(context: ToolUseContext): void { 36: this.context = context 37: } 38: async isAvailable(): Promise<boolean> { 39: return this.backend.isAvailable() 40: } 41: async spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> { 42: const agentId = formatAgentId(config.name, config.teamName) 43: if (!this.context) { 44: logForDebugging( 45: `[PaneBackendExecutor] spawn() called without context for ${config.name}`, 46: ) 47: return { 48: success: false, 49: agentId, 50: error: 51: 'PaneBackendExecutor not initialized. Call setContext() before spawn().', 52: } 53: } 54: try { 55: const teammateColor = config.color ?? assignTeammateColor(agentId) 56: const { paneId, isFirstTeammate } = 57: await this.backend.createTeammatePaneInSwarmView( 58: config.name, 59: teammateColor, 60: ) 61: const insideTmux = await isInsideTmux() 62: if (isFirstTeammate && insideTmux) { 63: await this.backend.enablePaneBorderStatus() 64: } 65: const binaryPath = getTeammateCommand() 66: const teammateArgs = [ 67: `--agent-id ${quote([agentId])}`, 68: `--agent-name ${quote([config.name])}`, 69: `--team-name ${quote([config.teamName])}`, 70: `--agent-color ${quote([teammateColor])}`, 71: `--parent-session-id ${quote([config.parentSessionId || getSessionId()])}`, 72: config.planModeRequired ? '--plan-mode-required' : '', 73: ] 74: .filter(Boolean) 75: .join(' ') 76: // Build CLI flags to propagate to teammate 77: const appState = this.context.getAppState() 78: let inheritedFlags = buildInheritedCliFlags({ 79: planModeRequired: config.planModeRequired, 80: permissionMode: appState.toolPermissionContext.mode, 81: }) 82: // If teammate has a custom model, add --model flag (or replace inherited one) 83: if (config.model) { 84: inheritedFlags = inheritedFlags 85: .split(' ') 86: .filter( 87: (flag, i, arr) => flag !== '--model' && arr[i - 1] !== '--model', 88: ) 89: .join(' ') 90: inheritedFlags = inheritedFlags 91: ? `${inheritedFlags} --model ${quote([config.model])}` 92: : `--model ${quote([config.model])}` 93: } 94: const flagsStr = inheritedFlags ? ` ${inheritedFlags}` : '' 95: const workingDir = config.cwd 96: // Build environment variables to forward to teammate 97: const envStr = buildInheritedEnvVars() 98: const spawnCommand = `cd ${quote([workingDir])} && env ${envStr} ${quote([binaryPath])} ${teammateArgs}${flagsStr}` 99: // Send the command to the new pane 100: // Use swarm socket when running outside tmux (external swarm session) 101: await this.backend.sendCommandToPane(paneId, spawnCommand, !insideTmux) 102: // Track the spawned teammate 103: this.spawnedTeammates.set(agentId, { paneId, insideTmux }) 104: // Register cleanup to kill all panes on leader exit (e.g., SIGHUP) 105: if (!this.cleanupRegistered) { 106: this.cleanupRegistered = true 107: registerCleanup(async () => { 108: for (const [id, info] of this.spawnedTeammates) { 109: logForDebugging( 110: `[PaneBackendExecutor] Cleanup: killing pane for ${id}`, 111: ) 112: await this.backend.killPane(info.paneId, !info.insideTmux) 113: } 114: this.spawnedTeammates.clear() 115: }) 116: } 117: // Send initial instructions to teammate via mailbox 118: await writeToMailbox( 119: config.name, 120: { 121: from: 'team-lead', 122: text: config.prompt, 123: timestamp: new Date().toISOString(), 124: }, 125: config.teamName, 126: ) 127: logForDebugging( 128: `[PaneBackendExecutor] Spawned teammate ${agentId} in pane ${paneId}`, 129: ) 130: return { 131: success: true, 132: agentId, 133: paneId, 134: } 135: } catch (error) { 136: const errorMessage = 137: error instanceof Error ? error.message : String(error) 138: logForDebugging( 139: `[PaneBackendExecutor] Failed to spawn ${agentId}: ${errorMessage}`, 140: ) 141: return { 142: success: false, 143: agentId, 144: error: errorMessage, 145: } 146: } 147: } 148: async sendMessage(agentId: string, message: TeammateMessage): Promise<void> { 149: logForDebugging( 150: `[PaneBackendExecutor] sendMessage() to ${agentId}: ${message.text.substring(0, 50)}...`, 151: ) 152: const parsed = parseAgentId(agentId) 153: if (!parsed) { 154: throw new Error( 155: `Invalid agentId format: ${agentId}. Expected format: agentName@teamName`, 156: ) 157: } 158: const { agentName, teamName } = parsed 159: await writeToMailbox( 160: agentName, 161: { 162: text: message.text, 163: from: message.from, 164: color: message.color, 165: timestamp: message.timestamp ?? new Date().toISOString(), 166: }, 167: teamName, 168: ) 169: logForDebugging( 170: `[PaneBackendExecutor] sendMessage() completed for ${agentId}`, 171: ) 172: } 173: async terminate(agentId: string, reason?: string): Promise<boolean> { 174: logForDebugging( 175: `[PaneBackendExecutor] terminate() called for ${agentId}: ${reason}`, 176: ) 177: const parsed = parseAgentId(agentId) 178: if (!parsed) { 179: logForDebugging( 180: `[PaneBackendExecutor] terminate() failed: invalid agentId format`, 181: ) 182: return false 183: } 184: const { agentName, teamName } = parsed 185: const shutdownRequest = { 186: type: 'shutdown_request', 187: requestId: `shutdown-${agentId}-${Date.now()}`, 188: from: 'team-lead', 189: reason, 190: } 191: await writeToMailbox( 192: agentName, 193: { 194: from: 'team-lead', 195: text: jsonStringify(shutdownRequest), 196: timestamp: new Date().toISOString(), 197: }, 198: teamName, 199: ) 200: logForDebugging( 201: `[PaneBackendExecutor] terminate() sent shutdown request to ${agentId}`, 202: ) 203: return true 204: } 205: async kill(agentId: string): Promise<boolean> { 206: logForDebugging(`[PaneBackendExecutor] kill() called for ${agentId}`) 207: const teammateInfo = this.spawnedTeammates.get(agentId) 208: if (!teammateInfo) { 209: logForDebugging( 210: `[PaneBackendExecutor] kill() failed: teammate ${agentId} not found in spawned map`, 211: ) 212: return false 213: } 214: const { paneId, insideTmux } = teammateInfo 215: const killed = await this.backend.killPane(paneId, !insideTmux) 216: if (killed) { 217: this.spawnedTeammates.delete(agentId) 218: logForDebugging(`[PaneBackendExecutor] kill() succeeded for ${agentId}`) 219: } else { 220: logForDebugging(`[PaneBackendExecutor] kill() failed for ${agentId}`) 221: } 222: return killed 223: } 224: async isActive(agentId: string): Promise<boolean> { 225: logForDebugging(`[PaneBackendExecutor] isActive() called for ${agentId}`) 226: const teammateInfo = this.spawnedTeammates.get(agentId) 227: if (!teammateInfo) { 228: logForDebugging( 229: `[PaneBackendExecutor] isActive(): teammate ${agentId} not found`, 230: ) 231: return false 232: } 233: return true 234: } 235: } 236: export function createPaneBackendExecutor( 237: backend: PaneBackend, 238: ): PaneBackendExecutor { 239: return new PaneBackendExecutor(backend) 240: }

File: src/utils/swarm/backends/registry.ts

typescript 1: import { getIsNonInteractiveSession } from '../../../bootstrap/state.js' 2: import { logForDebugging } from '../../../utils/debug.js' 3: import { getPlatform } from '../../../utils/platform.js' 4: import { 5: isInITerm2, 6: isInsideTmux, 7: isInsideTmuxSync, 8: isIt2CliAvailable, 9: isTmuxAvailable, 10: } from './detection.js' 11: import { createInProcessBackend } from './InProcessBackend.js' 12: import { getPreferTmuxOverIterm2 } from './it2Setup.js' 13: import { createPaneBackendExecutor } from './PaneBackendExecutor.js' 14: import { getTeammateModeFromSnapshot } from './teammateModeSnapshot.js' 15: import type { 16: BackendDetectionResult, 17: PaneBackend, 18: PaneBackendType, 19: TeammateExecutor, 20: } from './types.js' 21: let cachedBackend: PaneBackend | null = null 22: let cachedDetectionResult: BackendDetectionResult | null = null 23: let backendsRegistered = false 24: let cachedInProcessBackend: TeammateExecutor | null = null 25: let cachedPaneBackendExecutor: TeammateExecutor | null = null 26: let inProcessFallbackActive = false 27: let TmuxBackendClass: (new () => PaneBackend) | null = null 28: let ITermBackendClass: (new () => PaneBackend) | null = null 29: export async function ensureBackendsRegistered(): Promise<void> { 30: if (backendsRegistered) return 31: await import('./TmuxBackend.js') 32: await import('./ITermBackend.js') 33: backendsRegistered = true 34: } 35: export function registerTmuxBackend(backendClass: new () => PaneBackend): void { 36: TmuxBackendClass = backendClass 37: } 38: export function registerITermBackend( 39: backendClass: new () => PaneBackend, 40: ): void { 41: logForDebugging( 42: `[registry] registerITermBackend called, class=${backendClass?.name || 'undefined'}`, 43: ) 44: ITermBackendClass = backendClass 45: } 46: function createTmuxBackend(): PaneBackend { 47: if (!TmuxBackendClass) { 48: throw new Error( 49: 'TmuxBackend not registered. Import TmuxBackend.ts before using the registry.', 50: ) 51: } 52: return new TmuxBackendClass() 53: } 54: function createITermBackend(): PaneBackend { 55: if (!ITermBackendClass) { 56: throw new Error( 57: 'ITermBackend not registered. Import ITermBackend.ts before using the registry.', 58: ) 59: } 60: return new ITermBackendClass() 61: } 62: export async function detectAndGetBackend(): Promise<BackendDetectionResult> { 63: await ensureBackendsRegistered() 64: if (cachedDetectionResult) { 65: logForDebugging( 66: `[BackendRegistry] Using cached backend: ${cachedDetectionResult.backend.type}`, 67: ) 68: return cachedDetectionResult 69: } 70: logForDebugging('[BackendRegistry] Starting backend detection...') 71: const insideTmux = await isInsideTmux() 72: const inITerm2 = isInITerm2() 73: logForDebugging( 74: `[BackendRegistry] Environment: insideTmux=${insideTmux}, inITerm2=${inITerm2}`, 75: ) 76: if (insideTmux) { 77: logForDebugging( 78: '[BackendRegistry] Selected: tmux (running inside tmux session)', 79: ) 80: const backend = createTmuxBackend() 81: cachedBackend = backend 82: cachedDetectionResult = { 83: backend, 84: isNative: true, 85: needsIt2Setup: false, 86: } 87: return cachedDetectionResult 88: } 89: if (inITerm2) { 90: const preferTmux = getPreferTmuxOverIterm2() 91: if (preferTmux) { 92: logForDebugging( 93: '[BackendRegistry] User prefers tmux over iTerm2, skipping iTerm2 detection', 94: ) 95: } else { 96: const it2Available = await isIt2CliAvailable() 97: logForDebugging( 98: `[BackendRegistry] iTerm2 detected, it2 CLI available: ${it2Available}`, 99: ) 100: if (it2Available) { 101: logForDebugging( 102: '[BackendRegistry] Selected: iterm2 (native iTerm2 with it2 CLI)', 103: ) 104: const backend = createITermBackend() 105: cachedBackend = backend 106: cachedDetectionResult = { 107: backend, 108: isNative: true, 109: needsIt2Setup: false, 110: } 111: return cachedDetectionResult 112: } 113: } 114: const tmuxAvailable = await isTmuxAvailable() 115: logForDebugging( 116: `[BackendRegistry] it2 not available, tmux available: ${tmuxAvailable}`, 117: ) 118: if (tmuxAvailable) { 119: logForDebugging( 120: '[BackendRegistry] Selected: tmux (fallback in iTerm2, it2 setup recommended)', 121: ) 122: const backend = createTmuxBackend() 123: cachedBackend = backend 124: cachedDetectionResult = { 125: backend, 126: isNative: false, 127: needsIt2Setup: !preferTmux, 128: } 129: return cachedDetectionResult 130: } 131: logForDebugging( 132: '[BackendRegistry] ERROR: iTerm2 detected but no it2 CLI and no tmux', 133: ) 134: throw new Error( 135: 'iTerm2 detected but it2 CLI not installed. Install it2 with: pip install it2', 136: ) 137: } 138: const tmuxAvailable = await isTmuxAvailable() 139: logForDebugging( 140: `[BackendRegistry] Not in tmux or iTerm2, tmux available: ${tmuxAvailable}`, 141: ) 142: if (tmuxAvailable) { 143: logForDebugging('[BackendRegistry] Selected: tmux (external session mode)') 144: const backend = createTmuxBackend() 145: cachedBackend = backend 146: cachedDetectionResult = { 147: backend, 148: isNative: false, 149: needsIt2Setup: false, 150: } 151: return cachedDetectionResult 152: } 153: logForDebugging('[BackendRegistry] ERROR: No pane backend available') 154: throw new Error(getTmuxInstallInstructions()) 155: } 156: function getTmuxInstallInstructions(): string { 157: const platform = getPlatform() 158: switch (platform) { 159: case 'macos': 160: return `To use agent swarms, install tmux: 161: brew install tmux 162: Then start a tmux session with: tmux new-session -s claude` 163: case 'linux': 164: case 'wsl': 165: return `To use agent swarms, install tmux: 166: sudo apt install tmux # Ubuntu/Debian 167: sudo dnf install tmux # Fedora/RHEL 168: Then start a tmux session with: tmux new-session -s claude` 169: case 'windows': 170: return `To use agent swarms, you need tmux which requires WSL (Windows Subsystem for Linux). 171: Install WSL first, then inside WSL run: 172: sudo apt install tmux 173: Then start a tmux session with: tmux new-session -s claude` 174: default: 175: return `To use agent swarms, install tmux using your system's package manager. 176: Then start a tmux session with: tmux new-session -s claude` 177: } 178: } 179: export function getBackendByType(type: PaneBackendType): PaneBackend { 180: switch (type) { 181: case 'tmux': 182: return createTmuxBackend() 183: case 'iterm2': 184: return createITermBackend() 185: } 186: } 187: export function getCachedBackend(): PaneBackend | null { 188: return cachedBackend 189: } 190: export function getCachedDetectionResult(): BackendDetectionResult | null { 191: return cachedDetectionResult 192: } 193: export function markInProcessFallback(): void { 194: logForDebugging('[BackendRegistry] Marking in-process fallback as active') 195: inProcessFallbackActive = true 196: } 197: function getTeammateMode(): 'auto' | 'tmux' | 'in-process' { 198: return getTeammateModeFromSnapshot() 199: } 200: export function isInProcessEnabled(): boolean { 201: if (getIsNonInteractiveSession()) { 202: logForDebugging( 203: '[BackendRegistry] isInProcessEnabled: true (non-interactive session)', 204: ) 205: return true 206: } 207: const mode = getTeammateMode() 208: let enabled: boolean 209: if (mode === 'in-process') { 210: enabled = true 211: } else if (mode === 'tmux') { 212: enabled = false 213: } else { 214: if (inProcessFallbackActive) { 215: logForDebugging( 216: '[BackendRegistry] isInProcessEnabled: true (fallback after pane backend unavailable)', 217: ) 218: return true 219: } 220: const insideTmux = isInsideTmuxSync() 221: const inITerm2 = isInITerm2() 222: enabled = !insideTmux && !inITerm2 223: } 224: logForDebugging( 225: `[BackendRegistry] isInProcessEnabled: ${enabled} (mode=${mode}, insideTmux=${isInsideTmuxSync()}, inITerm2=${isInITerm2()})`, 226: ) 227: return enabled 228: } 229: export function getResolvedTeammateMode(): 'in-process' | 'tmux' { 230: return isInProcessEnabled() ? 'in-process' : 'tmux' 231: } 232: export function getInProcessBackend(): TeammateExecutor { 233: if (!cachedInProcessBackend) { 234: cachedInProcessBackend = createInProcessBackend() 235: } 236: return cachedInProcessBackend 237: } 238: export async function getTeammateExecutor( 239: preferInProcess: boolean = false, 240: ): Promise<TeammateExecutor> { 241: if (preferInProcess && isInProcessEnabled()) { 242: logForDebugging('[BackendRegistry] Using in-process executor') 243: return getInProcessBackend() 244: } 245: logForDebugging('[BackendRegistry] Using pane backend executor') 246: return getPaneBackendExecutor() 247: } 248: async function getPaneBackendExecutor(): Promise<TeammateExecutor> { 249: if (!cachedPaneBackendExecutor) { 250: const detection = await detectAndGetBackend() 251: cachedPaneBackendExecutor = createPaneBackendExecutor(detection.backend) 252: logForDebugging( 253: `[BackendRegistry] Created PaneBackendExecutor wrapping ${detection.backend.type}`, 254: ) 255: } 256: return cachedPaneBackendExecutor 257: } 258: export function resetBackendDetection(): void { 259: cachedBackend = null 260: cachedDetectionResult = null 261: cachedInProcessBackend = null 262: cachedPaneBackendExecutor = null 263: backendsRegistered = false 264: inProcessFallbackActive = false 265: }

File: src/utils/swarm/backends/teammateModeSnapshot.ts

typescript 1: import { getGlobalConfig } from '../../../utils/config.js' 2: import { logForDebugging } from '../../../utils/debug.js' 3: import { logError } from '../../../utils/log.js' 4: export type TeammateMode = 'auto' | 'tmux' | 'in-process' 5: let initialTeammateMode: TeammateMode | null = null 6: let cliTeammateModeOverride: TeammateMode | null = null 7: export function setCliTeammateModeOverride(mode: TeammateMode): void { 8: cliTeammateModeOverride = mode 9: } 10: export function getCliTeammateModeOverride(): TeammateMode | null { 11: return cliTeammateModeOverride 12: } 13: export function clearCliTeammateModeOverride(newMode: TeammateMode): void { 14: cliTeammateModeOverride = null 15: initialTeammateMode = newMode 16: logForDebugging( 17: `[TeammateModeSnapshot] CLI override cleared, new mode: ${newMode}`, 18: ) 19: } 20: export function captureTeammateModeSnapshot(): void { 21: if (cliTeammateModeOverride) { 22: initialTeammateMode = cliTeammateModeOverride 23: logForDebugging( 24: `[TeammateModeSnapshot] Captured from CLI override: ${initialTeammateMode}`, 25: ) 26: } else { 27: const config = getGlobalConfig() 28: initialTeammateMode = config.teammateMode ?? 'auto' 29: logForDebugging( 30: `[TeammateModeSnapshot] Captured from config: ${initialTeammateMode}`, 31: ) 32: } 33: } 34: export function getTeammateModeFromSnapshot(): TeammateMode { 35: if (initialTeammateMode === null) { 36: logError( 37: new Error( 38: 'getTeammateModeFromSnapshot called before capture - this indicates an initialization bug', 39: ), 40: ) 41: captureTeammateModeSnapshot() 42: } 43: return initialTeammateMode ?? 'auto' 44: }

File: src/utils/swarm/backends/TmuxBackend.ts

typescript 1: import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' 2: import { logForDebugging } from '../../../utils/debug.js' 3: import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' 4: import { logError } from '../../../utils/log.js' 5: import { count } from '../../array.js' 6: import { sleep } from '../../sleep.js' 7: import { 8: getSwarmSocketName, 9: HIDDEN_SESSION_NAME, 10: SWARM_SESSION_NAME, 11: SWARM_VIEW_WINDOW_NAME, 12: TMUX_COMMAND, 13: } from '../constants.js' 14: import { 15: getLeaderPaneId, 16: isInsideTmux as isInsideTmuxFromDetection, 17: isTmuxAvailable, 18: } from './detection.js' 19: import { registerTmuxBackend } from './registry.js' 20: import type { CreatePaneResult, PaneBackend, PaneId } from './types.js' 21: let firstPaneUsedForExternal = false 22: let cachedLeaderWindowTarget: string | null = null 23: let paneCreationLock: Promise<void> = Promise.resolve() 24: const PANE_SHELL_INIT_DELAY_MS = 200 25: function waitForPaneShellReady(): Promise<void> { 26: return sleep(PANE_SHELL_INIT_DELAY_MS) 27: } 28: function acquirePaneCreationLock(): Promise<() => void> { 29: let release: () => void 30: const newLock = new Promise<void>(resolve => { 31: release = resolve 32: }) 33: const previousLock = paneCreationLock 34: paneCreationLock = newLock 35: return previousLock.then(() => release!) 36: } 37: function getTmuxColorName(color: AgentColorName): string { 38: const tmuxColors: Record<AgentColorName, string> = { 39: red: 'red', 40: blue: 'blue', 41: green: 'green', 42: yellow: 'yellow', 43: purple: 'magenta', 44: orange: 'colour208', 45: pink: 'colour205', 46: cyan: 'cyan', 47: } 48: return tmuxColors[color] 49: } 50: function runTmuxInUserSession( 51: args: string[], 52: ): Promise<{ stdout: string; stderr: string; code: number }> { 53: return execFileNoThrow(TMUX_COMMAND, args) 54: } 55: function runTmuxInSwarm( 56: args: string[], 57: ): Promise<{ stdout: string; stderr: string; code: number }> { 58: return execFileNoThrow(TMUX_COMMAND, ['-L', getSwarmSocketName(), ...args]) 59: } 60: export class TmuxBackend implements PaneBackend { 61: readonly type = 'tmux' as const 62: readonly displayName = 'tmux' 63: readonly supportsHideShow = true 64: async isAvailable(): Promise<boolean> { 65: return isTmuxAvailable() 66: } 67: async isRunningInside(): Promise<boolean> { 68: return isInsideTmuxFromDetection() 69: } 70: async createTeammatePaneInSwarmView( 71: name: string, 72: color: AgentColorName, 73: ): Promise<CreatePaneResult> { 74: const releaseLock = await acquirePaneCreationLock() 75: try { 76: const insideTmux = await this.isRunningInside() 77: if (insideTmux) { 78: return await this.createTeammatePaneWithLeader(name, color) 79: } 80: return await this.createTeammatePaneExternal(name, color) 81: } finally { 82: releaseLock() 83: } 84: } 85: async sendCommandToPane( 86: paneId: PaneId, 87: command: string, 88: useExternalSession = false, 89: ): Promise<void> { 90: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 91: const result = await runTmux(['send-keys', '-t', paneId, command, 'Enter']) 92: if (result.code !== 0) { 93: throw new Error( 94: `Failed to send command to pane ${paneId}: ${result.stderr}`, 95: ) 96: } 97: } 98: async setPaneBorderColor( 99: paneId: PaneId, 100: color: AgentColorName, 101: useExternalSession = false, 102: ): Promise<void> { 103: const tmuxColor = getTmuxColorName(color) 104: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 105: await runTmux([ 106: 'select-pane', 107: '-t', 108: paneId, 109: '-P', 110: `bg=default,fg=${tmuxColor}`, 111: ]) 112: await runTmux([ 113: 'set-option', 114: '-p', 115: '-t', 116: paneId, 117: 'pane-border-style', 118: `fg=${tmuxColor}`, 119: ]) 120: await runTmux([ 121: 'set-option', 122: '-p', 123: '-t', 124: paneId, 125: 'pane-active-border-style', 126: `fg=${tmuxColor}`, 127: ]) 128: } 129: async setPaneTitle( 130: paneId: PaneId, 131: name: string, 132: color: AgentColorName, 133: useExternalSession = false, 134: ): Promise<void> { 135: const tmuxColor = getTmuxColorName(color) 136: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 137: await runTmux(['select-pane', '-t', paneId, '-T', name]) 138: await runTmux([ 139: 'set-option', 140: '-p', 141: '-t', 142: paneId, 143: 'pane-border-format', 144: `#[fg=${tmuxColor},bold] #{pane_title} #[default]`, 145: ]) 146: } 147: async enablePaneBorderStatus( 148: windowTarget?: string, 149: useExternalSession = false, 150: ): Promise<void> { 151: const target = windowTarget || (await this.getCurrentWindowTarget()) 152: if (!target) { 153: return 154: } 155: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 156: await runTmux([ 157: 'set-option', 158: '-w', 159: '-t', 160: target, 161: 'pane-border-status', 162: 'top', 163: ]) 164: } 165: async rebalancePanes( 166: windowTarget: string, 167: hasLeader: boolean, 168: ): Promise<void> { 169: if (hasLeader) { 170: await this.rebalancePanesWithLeader(windowTarget) 171: } else { 172: await this.rebalancePanesTiled(windowTarget) 173: } 174: } 175: async killPane(paneId: PaneId, useExternalSession = false): Promise<boolean> { 176: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 177: const result = await runTmux(['kill-pane', '-t', paneId]) 178: return result.code === 0 179: } 180: async hidePane(paneId: PaneId, useExternalSession = false): Promise<boolean> { 181: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 182: await runTmux(['new-session', '-d', '-s', HIDDEN_SESSION_NAME]) 183: const result = await runTmux([ 184: 'break-pane', 185: '-d', 186: '-s', 187: paneId, 188: '-t', 189: `${HIDDEN_SESSION_NAME}:`, 190: ]) 191: if (result.code === 0) { 192: logForDebugging(`[TmuxBackend] Hidden pane ${paneId}`) 193: } else { 194: logForDebugging( 195: `[TmuxBackend] Failed to hide pane ${paneId}: ${result.stderr}`, 196: ) 197: } 198: return result.code === 0 199: } 200: async showPane( 201: paneId: PaneId, 202: targetWindowOrPane: string, 203: useExternalSession = false, 204: ): Promise<boolean> { 205: const runTmux = useExternalSession ? runTmuxInSwarm : runTmuxInUserSession 206: const result = await runTmux([ 207: 'join-pane', 208: '-h', 209: '-s', 210: paneId, 211: '-t', 212: targetWindowOrPane, 213: ]) 214: if (result.code !== 0) { 215: logForDebugging( 216: `[TmuxBackend] Failed to show pane ${paneId}: ${result.stderr}`, 217: ) 218: return false 219: } 220: logForDebugging( 221: `[TmuxBackend] Showed pane ${paneId} in ${targetWindowOrPane}`, 222: ) 223: await runTmux(['select-layout', '-t', targetWindowOrPane, 'main-vertical']) 224: const panesResult = await runTmux([ 225: 'list-panes', 226: '-t', 227: targetWindowOrPane, 228: '-F', 229: '#{pane_id}', 230: ]) 231: const panes = panesResult.stdout.trim().split('\n').filter(Boolean) 232: if (panes[0]) { 233: await runTmux(['resize-pane', '-t', panes[0], '-x', '30%']) 234: } 235: return true 236: } 237: private async getCurrentPaneId(): Promise<string | null> { 238: const leaderPane = getLeaderPaneId() 239: if (leaderPane) { 240: return leaderPane 241: } 242: const result = await execFileNoThrow(TMUX_COMMAND, [ 243: 'display-message', 244: '-p', 245: '#{pane_id}', 246: ]) 247: if (result.code !== 0) { 248: logForDebugging( 249: `[TmuxBackend] Failed to get current pane ID (exit ${result.code}): ${result.stderr}`, 250: ) 251: return null 252: } 253: return result.stdout.trim() 254: } 255: private async getCurrentWindowTarget(): Promise<string | null> { 256: if (cachedLeaderWindowTarget) { 257: return cachedLeaderWindowTarget 258: } 259: const leaderPane = getLeaderPaneId() 260: const args = ['display-message'] 261: if (leaderPane) { 262: args.push('-t', leaderPane) 263: } 264: args.push('-p', '#{session_name}:#{window_index}') 265: const result = await execFileNoThrow(TMUX_COMMAND, args) 266: if (result.code !== 0) { 267: logForDebugging( 268: `[TmuxBackend] Failed to get current window target (exit ${result.code}): ${result.stderr}`, 269: ) 270: return null 271: } 272: cachedLeaderWindowTarget = result.stdout.trim() 273: return cachedLeaderWindowTarget 274: } 275: private async getCurrentWindowPaneCount( 276: windowTarget?: string, 277: useSwarmSocket = false, 278: ): Promise<number | null> { 279: const target = windowTarget || (await this.getCurrentWindowTarget()) 280: if (!target) { 281: return null 282: } 283: const args = ['list-panes', '-t', target, '-F', '#{pane_id}'] 284: const result = useSwarmSocket 285: ? await runTmuxInSwarm(args) 286: : await runTmuxInUserSession(args) 287: if (result.code !== 0) { 288: logError( 289: new Error( 290: `[TmuxBackend] Failed to get pane count for ${target} (exit ${result.code}): ${result.stderr}`, 291: ), 292: ) 293: return null 294: } 295: return count(result.stdout.trim().split('\n'), Boolean) 296: } 297: private async hasSessionInSwarm(sessionName: string): Promise<boolean> { 298: const result = await runTmuxInSwarm(['has-session', '-t', sessionName]) 299: return result.code === 0 300: } 301: private async createExternalSwarmSession(): Promise<{ 302: windowTarget: string 303: paneId: string 304: }> { 305: const sessionExists = await this.hasSessionInSwarm(SWARM_SESSION_NAME) 306: if (!sessionExists) { 307: const result = await runTmuxInSwarm([ 308: 'new-session', 309: '-d', 310: '-s', 311: SWARM_SESSION_NAME, 312: '-n', 313: SWARM_VIEW_WINDOW_NAME, 314: '-P', 315: '-F', 316: '#{pane_id}', 317: ]) 318: if (result.code !== 0) { 319: throw new Error( 320: `Failed to create swarm session: ${result.stderr || 'Unknown error'}`, 321: ) 322: } 323: const paneId = result.stdout.trim() 324: const windowTarget = `${SWARM_SESSION_NAME}:${SWARM_VIEW_WINDOW_NAME}` 325: logForDebugging( 326: `[TmuxBackend] Created external swarm session with window ${windowTarget}, pane ${paneId}`, 327: ) 328: return { windowTarget, paneId } 329: } 330: const listResult = await runTmuxInSwarm([ 331: 'list-windows', 332: '-t', 333: SWARM_SESSION_NAME, 334: '-F', 335: '#{window_name}', 336: ]) 337: const windows = listResult.stdout.trim().split('\n').filter(Boolean) 338: const windowTarget = `${SWARM_SESSION_NAME}:${SWARM_VIEW_WINDOW_NAME}` 339: if (windows.includes(SWARM_VIEW_WINDOW_NAME)) { 340: const paneResult = await runTmuxInSwarm([ 341: 'list-panes', 342: '-t', 343: windowTarget, 344: '-F', 345: '#{pane_id}', 346: ]) 347: const panes = paneResult.stdout.trim().split('\n').filter(Boolean) 348: return { windowTarget, paneId: panes[0] || '' } 349: } 350: // Create the swarm-view window 351: const createResult = await runTmuxInSwarm([ 352: 'new-window', 353: '-t', 354: SWARM_SESSION_NAME, 355: '-n', 356: SWARM_VIEW_WINDOW_NAME, 357: '-P', 358: '-F', 359: '#{pane_id}', 360: ]) 361: if (createResult.code !== 0) { 362: throw new Error( 363: `Failed to create swarm-view window: ${createResult.stderr || 'Unknown error'}`, 364: ) 365: } 366: return { windowTarget, paneId: createResult.stdout.trim() } 367: } 368: private async createTeammatePaneWithLeader( 369: teammateName: string, 370: teammateColor: AgentColorName, 371: ): Promise<CreatePaneResult> { 372: const currentPaneId = await this.getCurrentPaneId() 373: const windowTarget = await this.getCurrentWindowTarget() 374: if (!currentPaneId || !windowTarget) { 375: throw new Error('Could not determine current tmux pane/window') 376: } 377: const paneCount = await this.getCurrentWindowPaneCount(windowTarget) 378: if (paneCount === null) { 379: throw new Error('Could not determine pane count for current window') 380: } 381: const isFirstTeammate = paneCount === 1 382: let splitResult 383: if (isFirstTeammate) { 384: splitResult = await execFileNoThrow(TMUX_COMMAND, [ 385: 'split-window', 386: '-t', 387: currentPaneId, 388: '-h', 389: '-l', 390: '70%', 391: '-P', 392: '-F', 393: '#{pane_id}', 394: ]) 395: } else { 396: const listResult = await execFileNoThrow(TMUX_COMMAND, [ 397: 'list-panes', 398: '-t', 399: windowTarget, 400: '-F', 401: '#{pane_id}', 402: ]) 403: const panes = listResult.stdout.trim().split('\n').filter(Boolean) 404: const teammatePanes = panes.slice(1) 405: const teammateCount = teammatePanes.length 406: const splitVertically = teammateCount % 2 === 1 407: const targetPaneIndex = Math.floor((teammateCount - 1) / 2) 408: const targetPane = 409: teammatePanes[targetPaneIndex] || 410: teammatePanes[teammatePanes.length - 1] 411: splitResult = await execFileNoThrow(TMUX_COMMAND, [ 412: 'split-window', 413: '-t', 414: targetPane!, 415: splitVertically ? '-v' : '-h', 416: '-P', 417: '-F', 418: '#{pane_id}', 419: ]) 420: } 421: if (splitResult.code !== 0) { 422: throw new Error(`Failed to create teammate pane: ${splitResult.stderr}`) 423: } 424: const paneId = splitResult.stdout.trim() 425: logForDebugging( 426: `[TmuxBackend] Created teammate pane for ${teammateName}: ${paneId}`, 427: ) 428: await this.setPaneBorderColor(paneId, teammateColor) 429: await this.setPaneTitle(paneId, teammateName, teammateColor) 430: await this.rebalancePanesWithLeader(windowTarget) 431: await waitForPaneShellReady() 432: return { paneId, isFirstTeammate } 433: } 434: private async createTeammatePaneExternal( 435: teammateName: string, 436: teammateColor: AgentColorName, 437: ): Promise<CreatePaneResult> { 438: const { windowTarget, paneId: firstPaneId } = 439: await this.createExternalSwarmSession() 440: const paneCount = await this.getCurrentWindowPaneCount(windowTarget, true) 441: if (paneCount === null) { 442: throw new Error('Could not determine pane count for swarm window') 443: } 444: const isFirstTeammate = !firstPaneUsedForExternal && paneCount === 1 445: let paneId: string 446: if (isFirstTeammate) { 447: paneId = firstPaneId 448: firstPaneUsedForExternal = true 449: logForDebugging( 450: `[TmuxBackend] Using initial pane for first teammate ${teammateName}: ${paneId}`, 451: ) 452: await this.enablePaneBorderStatus(windowTarget, true) 453: } else { 454: const listResult = await runTmuxInSwarm([ 455: 'list-panes', 456: '-t', 457: windowTarget, 458: '-F', 459: '#{pane_id}', 460: ]) 461: const panes = listResult.stdout.trim().split('\n').filter(Boolean) 462: const teammateCount = panes.length 463: const splitVertically = teammateCount % 2 === 1 464: const targetPaneIndex = Math.floor((teammateCount - 1) / 2) 465: const targetPane = panes[targetPaneIndex] || panes[panes.length - 1] 466: const splitResult = await runTmuxInSwarm([ 467: 'split-window', 468: '-t', 469: targetPane!, 470: splitVertically ? '-v' : '-h', 471: '-P', 472: '-F', 473: '#{pane_id}', 474: ]) 475: if (splitResult.code !== 0) { 476: throw new Error(`Failed to create teammate pane: ${splitResult.stderr}`) 477: } 478: paneId = splitResult.stdout.trim() 479: logForDebugging( 480: `[TmuxBackend] Created teammate pane for ${teammateName}: ${paneId}`, 481: ) 482: } 483: await this.setPaneBorderColor(paneId, teammateColor, true) 484: await this.setPaneTitle(paneId, teammateName, teammateColor, true) 485: await this.rebalancePanesTiled(windowTarget) 486: await waitForPaneShellReady() 487: return { paneId, isFirstTeammate } 488: } 489: private async rebalancePanesWithLeader(windowTarget: string): Promise<void> { 490: const listResult = await runTmuxInUserSession([ 491: 'list-panes', 492: '-t', 493: windowTarget, 494: '-F', 495: '#{pane_id}', 496: ]) 497: const panes = listResult.stdout.trim().split('\n').filter(Boolean) 498: if (panes.length <= 2) { 499: return 500: } 501: await runTmuxInUserSession([ 502: 'select-layout', 503: '-t', 504: windowTarget, 505: 'main-vertical', 506: ]) 507: const leaderPane = panes[0] 508: await runTmuxInUserSession(['resize-pane', '-t', leaderPane!, '-x', '30%']) 509: logForDebugging( 510: `[TmuxBackend] Rebalanced ${panes.length - 1} teammate panes with leader`, 511: ) 512: } 513: private async rebalancePanesTiled(windowTarget: string): Promise<void> { 514: const listResult = await runTmuxInSwarm([ 515: 'list-panes', 516: '-t', 517: windowTarget, 518: '-F', 519: '#{pane_id}', 520: ]) 521: const panes = listResult.stdout.trim().split('\n').filter(Boolean) 522: if (panes.length <= 1) { 523: return 524: } 525: await runTmuxInSwarm(['select-layout', '-t', windowTarget, 'tiled']) 526: logForDebugging( 527: `[TmuxBackend] Rebalanced ${panes.length} teammate panes with tiled layout`, 528: ) 529: } 530: } 531: registerTmuxBackend(TmuxBackend)

File: src/utils/swarm/backends/types.ts

typescript 1: import type { AgentColorName } from '../../../tools/AgentTool/agentColorManager.js' 2: export type BackendType = 'tmux' | 'iterm2' | 'in-process' 3: export type PaneBackendType = 'tmux' | 'iterm2' 4: export type PaneId = string 5: export type CreatePaneResult = { 6: paneId: PaneId 7: isFirstTeammate: boolean 8: } 9: export type PaneBackend = { 10: readonly type: BackendType 11: readonly displayName: string 12: readonly supportsHideShow: boolean 13: isAvailable(): Promise<boolean> 14: isRunningInside(): Promise<boolean> 15: createTeammatePaneInSwarmView( 16: name: string, 17: color: AgentColorName, 18: ): Promise<CreatePaneResult> 19: sendCommandToPane( 20: paneId: PaneId, 21: command: string, 22: useExternalSession?: boolean, 23: ): Promise<void> 24: setPaneBorderColor( 25: paneId: PaneId, 26: color: AgentColorName, 27: useExternalSession?: boolean, 28: ): Promise<void> 29: setPaneTitle( 30: paneId: PaneId, 31: name: string, 32: color: AgentColorName, 33: useExternalSession?: boolean, 34: ): Promise<void> 35: enablePaneBorderStatus( 36: windowTarget?: string, 37: useExternalSession?: boolean, 38: ): Promise<void> 39: rebalancePanes(windowTarget: string, hasLeader: boolean): Promise<void> 40: killPane(paneId: PaneId, useExternalSession?: boolean): Promise<boolean> 41: hidePane(paneId: PaneId, useExternalSession?: boolean): Promise<boolean> 42: showPane( 43: paneId: PaneId, 44: targetWindowOrPane: string, 45: useExternalSession?: boolean, 46: ): Promise<boolean> 47: } 48: export type BackendDetectionResult = { 49: backend: PaneBackend 50: isNative: boolean 51: needsIt2Setup?: boolean 52: } 53: export type TeammateIdentity = { 54: name: string 55: teamName: string 56: color?: AgentColorName 57: planModeRequired?: boolean 58: } 59: export type TeammateSpawnConfig = TeammateIdentity & { 60: prompt: string 61: cwd: string 62: model?: string 63: systemPrompt?: string 64: systemPromptMode?: 'default' | 'replace' | 'append' 65: worktreePath?: string 66: parentSessionId: string 67: permissions?: string[] 68: allowPermissionPrompts?: boolean 69: } 70: export type TeammateSpawnResult = { 71: success: boolean 72: agentId: string 73: error?: string 74: abortController?: AbortController 75: taskId?: string 76: paneId?: PaneId 77: } 78: export type TeammateMessage = { 79: text: string 80: from: string 81: color?: string 82: timestamp?: string 83: summary?: string 84: } 85: export type TeammateExecutor = { 86: readonly type: BackendType 87: isAvailable(): Promise<boolean> 88: spawn(config: TeammateSpawnConfig): Promise<TeammateSpawnResult> 89: sendMessage(agentId: string, message: TeammateMessage): Promise<void> 90: terminate(agentId: string, reason?: string): Promise<boolean> 91: kill(agentId: string): Promise<boolean> 92: isActive(agentId: string): Promise<boolean> 93: } 94: export function isPaneBackend(type: BackendType): type is 'tmux' | 'iterm2' { 95: return type === 'tmux' || type === 'iterm2' 96: }

File: src/utils/swarm/constants.ts

typescript 1: export const TEAM_LEAD_NAME = 'team-lead' 2: export const SWARM_SESSION_NAME = 'claude-swarm' 3: export const SWARM_VIEW_WINDOW_NAME = 'swarm-view' 4: export const TMUX_COMMAND = 'tmux' 5: export const HIDDEN_SESSION_NAME = 'claude-hidden' 6: export function getSwarmSocketName(): string { 7: return `claude-swarm-${process.pid}` 8: } 9: export const TEAMMATE_COMMAND_ENV_VAR = 'CLAUDE_CODE_TEAMMATE_COMMAND' 10: export const TEAMMATE_COLOR_ENV_VAR = 'CLAUDE_CODE_AGENT_COLOR' 11: export const PLAN_MODE_REQUIRED_ENV_VAR = 'CLAUDE_CODE_PLAN_MODE_REQUIRED'

File: src/utils/swarm/inProcessRunner.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' 3: import { getSystemPrompt } from '../../constants/prompts.js' 4: import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js' 5: import type { CanUseToolFn } from '../../hooks/useCanUseTool.js' 6: import { 7: processMailboxPermissionResponse, 8: registerPermissionCallback, 9: unregisterPermissionCallback, 10: } from '../../hooks/useSwarmPermissionPoller.js' 11: import { 12: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 13: logEvent, 14: } from '../../services/analytics/index.js' 15: import { getAutoCompactThreshold } from '../../services/compact/autoCompact.js' 16: import { 17: buildPostCompactMessages, 18: compactConversation, 19: ERROR_MESSAGE_USER_ABORT, 20: } from '../../services/compact/compact.js' 21: import { resetMicrocompactState } from '../../services/compact/microCompact.js' 22: import type { AppState } from '../../state/AppState.js' 23: import type { Tool, ToolUseContext } from '../../Tool.js' 24: import { appendTeammateMessage } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' 25: import type { 26: InProcessTeammateTaskState, 27: TeammateIdentity, 28: } from '../../tasks/InProcessTeammateTask/types.js' 29: import { appendCappedMessage } from '../../tasks/InProcessTeammateTask/types.js' 30: import { 31: createActivityDescriptionResolver, 32: createProgressTracker, 33: getProgressUpdate, 34: updateProgressFromMessage, 35: } from '../../tasks/LocalAgentTask/LocalAgentTask.js' 36: import type { CustomAgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' 37: import { runAgent } from '../../tools/AgentTool/runAgent.js' 38: import { awaitClassifierAutoApproval } from '../../tools/BashTool/bashPermissions.js' 39: import { BASH_TOOL_NAME } from '../../tools/BashTool/toolName.js' 40: import { SEND_MESSAGE_TOOL_NAME } from '../../tools/SendMessageTool/constants.js' 41: import { TASK_CREATE_TOOL_NAME } from '../../tools/TaskCreateTool/constants.js' 42: import { TASK_GET_TOOL_NAME } from '../../tools/TaskGetTool/constants.js' 43: import { TASK_LIST_TOOL_NAME } from '../../tools/TaskListTool/constants.js' 44: import { TASK_UPDATE_TOOL_NAME } from '../../tools/TaskUpdateTool/constants.js' 45: import { TEAM_CREATE_TOOL_NAME } from '../../tools/TeamCreateTool/constants.js' 46: import { TEAM_DELETE_TOOL_NAME } from '../../tools/TeamDeleteTool/constants.js' 47: import type { Message } from '../../types/message.js' 48: import type { PermissionDecision } from '../../types/permissions.js' 49: import { 50: createAssistantAPIErrorMessage, 51: createUserMessage, 52: } from '../../utils/messages.js' 53: import { evictTaskOutput } from '../../utils/task/diskOutput.js' 54: import { evictTerminalTask } from '../../utils/task/framework.js' 55: import { tokenCountWithEstimation } from '../../utils/tokens.js' 56: import { createAbortController } from '../abortController.js' 57: import { type AgentContext, runWithAgentContext } from '../agentContext.js' 58: import { count } from '../array.js' 59: import { logForDebugging } from '../debug.js' 60: import { cloneFileStateCache } from '../fileStateCache.js' 61: import { 62: SUBAGENT_REJECT_MESSAGE, 63: SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX, 64: } from '../messages.js' 65: import type { ModelAlias } from '../model/aliases.js' 66: import { 67: applyPermissionUpdates, 68: persistPermissionUpdates, 69: } from '../permissions/PermissionUpdate.js' 70: import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' 71: import { hasPermissionsToUseTool } from '../permissions/permissions.js' 72: import { emitTaskTerminatedSdk } from '../sdkEventQueue.js' 73: import { sleep } from '../sleep.js' 74: import { jsonStringify } from '../slowOperations.js' 75: import { asSystemPrompt } from '../systemPromptType.js' 76: import { claimTask, listTasks, type Task, updateTask } from '../tasks.js' 77: import type { TeammateContext } from '../teammateContext.js' 78: import { runWithTeammateContext } from '../teammateContext.js' 79: import { 80: createIdleNotification, 81: getLastPeerDmSummary, 82: isPermissionResponse, 83: isShutdownRequest, 84: markMessageAsReadByIndex, 85: readMailbox, 86: writeToMailbox, 87: } from '../teammateMailbox.js' 88: import { unregisterAgent as unregisterPerfettoAgent } from '../telemetry/perfettoTracing.js' 89: import { createContentReplacementState } from '../toolResultStorage.js' 90: import { TEAM_LEAD_NAME } from './constants.js' 91: import { 92: getLeaderSetToolPermissionContext, 93: getLeaderToolUseConfirmQueue, 94: } from './leaderPermissionBridge.js' 95: import { 96: createPermissionRequest, 97: sendPermissionRequestViaMailbox, 98: } from './permissionSync.js' 99: import { TEAMMATE_SYSTEM_PROMPT_ADDENDUM } from './teammatePromptAddendum.js' 100: type SetAppStateFn = (updater: (prev: AppState) => AppState) => void 101: const PERMISSION_POLL_INTERVAL_MS = 500 102: function createInProcessCanUseTool( 103: identity: TeammateIdentity, 104: abortController: AbortController, 105: onPermissionWaitMs?: (waitMs: number) => void, 106: ): CanUseToolFn { 107: return async ( 108: tool, 109: input, 110: toolUseContext, 111: assistantMessage, 112: toolUseID, 113: forceDecision, 114: ) => { 115: const result = 116: forceDecision ?? 117: (await hasPermissionsToUseTool( 118: tool, 119: input, 120: toolUseContext, 121: assistantMessage, 122: toolUseID, 123: )) 124: if (result.behavior !== 'ask') { 125: return result 126: } 127: if ( 128: feature('BASH_CLASSIFIER') && 129: tool.name === BASH_TOOL_NAME && 130: result.pendingClassifierCheck 131: ) { 132: const classifierDecision = await awaitClassifierAutoApproval( 133: result.pendingClassifierCheck, 134: abortController.signal, 135: toolUseContext.options.isNonInteractiveSession, 136: ) 137: if (classifierDecision) { 138: return { 139: behavior: 'allow', 140: updatedInput: input as Record<string, unknown>, 141: decisionReason: classifierDecision, 142: } 143: } 144: } 145: if (abortController.signal.aborted) { 146: return { behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE } 147: } 148: const appState = toolUseContext.getAppState() 149: const description = await (tool as Tool).description(input as never, { 150: isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, 151: toolPermissionContext: appState.toolPermissionContext, 152: tools: toolUseContext.options.tools, 153: }) 154: if (abortController.signal.aborted) { 155: return { behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE } 156: } 157: const setToolUseConfirmQueue = getLeaderToolUseConfirmQueue() 158: if (setToolUseConfirmQueue) { 159: return new Promise<PermissionDecision>(resolve => { 160: let decisionMade = false 161: const permissionStartMs = Date.now() 162: const reportPermissionWait = () => { 163: onPermissionWaitMs?.(Date.now() - permissionStartMs) 164: } 165: const onAbortListener = () => { 166: if (decisionMade) return 167: decisionMade = true 168: reportPermissionWait() 169: resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 170: setToolUseConfirmQueue(queue => 171: queue.filter(item => item.toolUseID !== toolUseID), 172: ) 173: } 174: abortController.signal.addEventListener('abort', onAbortListener, { 175: once: true, 176: }) 177: setToolUseConfirmQueue(queue => [ 178: ...queue, 179: { 180: assistantMessage, 181: tool: tool as Tool, 182: description, 183: input, 184: toolUseContext, 185: toolUseID, 186: permissionResult: result, 187: permissionPromptStartTimeMs: permissionStartMs, 188: workerBadge: identity.color 189: ? { name: identity.agentName, color: identity.color } 190: : undefined, 191: onUserInteraction() { 192: }, 193: onAbort() { 194: if (decisionMade) return 195: decisionMade = true 196: abortController.signal.removeEventListener( 197: 'abort', 198: onAbortListener, 199: ) 200: reportPermissionWait() 201: resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 202: }, 203: async onAllow( 204: updatedInput: Record<string, unknown>, 205: permissionUpdates: PermissionUpdate[], 206: feedback?: string, 207: contentBlocks?: ContentBlockParam[], 208: ) { 209: if (decisionMade) return 210: decisionMade = true 211: abortController.signal.removeEventListener( 212: 'abort', 213: onAbortListener, 214: ) 215: reportPermissionWait() 216: persistPermissionUpdates(permissionUpdates) 217: if (permissionUpdates.length > 0) { 218: const setToolPermissionContext = 219: getLeaderSetToolPermissionContext() 220: if (setToolPermissionContext) { 221: const currentAppState = toolUseContext.getAppState() 222: const updatedContext = applyPermissionUpdates( 223: currentAppState.toolPermissionContext, 224: permissionUpdates, 225: ) 226: setToolPermissionContext(updatedContext, { 227: preserveMode: true, 228: }) 229: } 230: } 231: const trimmedFeedback = feedback?.trim() 232: resolve({ 233: behavior: 'allow', 234: updatedInput, 235: userModified: false, 236: acceptFeedback: trimmedFeedback || undefined, 237: ...(contentBlocks && 238: contentBlocks.length > 0 && { contentBlocks }), 239: }) 240: }, 241: onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) { 242: if (decisionMade) return 243: decisionMade = true 244: abortController.signal.removeEventListener( 245: 'abort', 246: onAbortListener, 247: ) 248: reportPermissionWait() 249: const message = feedback 250: ? `${SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}` 251: : SUBAGENT_REJECT_MESSAGE 252: resolve({ behavior: 'ask', message, contentBlocks }) 253: }, 254: async recheckPermission() { 255: if (decisionMade) return 256: const freshResult = await hasPermissionsToUseTool( 257: tool, 258: input, 259: toolUseContext, 260: assistantMessage, 261: toolUseID, 262: ) 263: if (freshResult.behavior === 'allow') { 264: decisionMade = true 265: abortController.signal.removeEventListener( 266: 'abort', 267: onAbortListener, 268: ) 269: reportPermissionWait() 270: setToolUseConfirmQueue(queue => 271: queue.filter(item => item.toolUseID !== toolUseID), 272: ) 273: resolve({ 274: ...freshResult, 275: updatedInput: input, 276: userModified: false, 277: }) 278: } 279: }, 280: }, 281: ]) 282: }) 283: } 284: return new Promise<PermissionDecision>(resolve => { 285: const request = createPermissionRequest({ 286: toolName: (tool as Tool).name, 287: toolUseId: toolUseID, 288: input, 289: description, 290: permissionSuggestions: result.suggestions, 291: workerId: identity.agentId, 292: workerName: identity.agentName, 293: workerColor: identity.color, 294: teamName: identity.teamName, 295: }) 296: registerPermissionCallback({ 297: requestId: request.id, 298: toolUseId: toolUseID, 299: onAllow( 300: updatedInput: Record<string, unknown> | undefined, 301: permissionUpdates: PermissionUpdate[], 302: _feedback?: string, 303: contentBlocks?: ContentBlockParam[], 304: ) { 305: cleanup() 306: persistPermissionUpdates(permissionUpdates) 307: const finalInput = 308: updatedInput && Object.keys(updatedInput).length > 0 309: ? updatedInput 310: : input 311: resolve({ 312: behavior: 'allow', 313: updatedInput: finalInput, 314: userModified: false, 315: ...(contentBlocks && contentBlocks.length > 0 && { contentBlocks }), 316: }) 317: }, 318: onReject(feedback?: string, contentBlocks?: ContentBlockParam[]) { 319: cleanup() 320: const message = feedback 321: ? `${SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX}${feedback}` 322: : SUBAGENT_REJECT_MESSAGE 323: resolve({ behavior: 'ask', message, contentBlocks }) 324: }, 325: }) 326: void sendPermissionRequestViaMailbox(request) 327: const pollInterval = setInterval( 328: async (abortController, cleanup, resolve, identity, request) => { 329: if (abortController.signal.aborted) { 330: cleanup() 331: resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 332: return 333: } 334: const allMessages = await readMailbox( 335: identity.agentName, 336: identity.teamName, 337: ) 338: for (let i = 0; i < allMessages.length; i++) { 339: const msg = allMessages[i] 340: if (msg && !msg.read) { 341: const parsed = isPermissionResponse(msg.text) 342: if (parsed && parsed.request_id === request.id) { 343: await markMessageAsReadByIndex( 344: identity.agentName, 345: identity.teamName, 346: i, 347: ) 348: if (parsed.subtype === 'success') { 349: processMailboxPermissionResponse({ 350: requestId: parsed.request_id, 351: decision: 'approved', 352: updatedInput: parsed.response?.updated_input, 353: permissionUpdates: parsed.response?.permission_updates, 354: }) 355: } else { 356: processMailboxPermissionResponse({ 357: requestId: parsed.request_id, 358: decision: 'rejected', 359: feedback: parsed.error, 360: }) 361: } 362: return 363: } 364: } 365: } 366: }, 367: PERMISSION_POLL_INTERVAL_MS, 368: abortController, 369: cleanup, 370: resolve, 371: identity, 372: request, 373: ) 374: const onAbortListener = () => { 375: cleanup() 376: resolve({ behavior: 'ask', message: SUBAGENT_REJECT_MESSAGE }) 377: } 378: abortController.signal.addEventListener('abort', onAbortListener, { 379: once: true, 380: }) 381: function cleanup() { 382: clearInterval(pollInterval) 383: unregisterPermissionCallback(request.id) 384: abortController.signal.removeEventListener('abort', onAbortListener) 385: } 386: }) 387: } 388: } 389: function formatAsTeammateMessage( 390: from: string, 391: content: string, 392: color?: string, 393: summary?: string, 394: ): string { 395: const colorAttr = color ? ` color="${color}"` : '' 396: const summaryAttr = summary ? ` summary="${summary}"` : '' 397: return `<${TEAMMATE_MESSAGE_TAG} teammate_id="${from}"${colorAttr}${summaryAttr}>\n${content}\n</${TEAMMATE_MESSAGE_TAG}>` 398: } 399: /** 400: * Configuration for running an in-process teammate. 401: */ 402: export type InProcessRunnerConfig = { 403: /** Teammate identity for context */ 404: identity: TeammateIdentity 405: /** Task ID in AppState */ 406: taskId: string 407: /** Initial prompt for the teammate */ 408: prompt: string 409: /** Optional agent definition (for specialized agents) */ 410: agentDefinition?: CustomAgentDefinition 411: /** Teammate context for AsyncLocalStorage */ 412: teammateContext: TeammateContext 413: /** Parent's tool use context */ 414: toolUseContext: ToolUseContext 415: abortController: AbortController 416: model?: string 417: systemPrompt?: string 418: systemPromptMode?: 'default' | 'replace' | 'append' 419: allowedTools?: string[] 420: allowPermissionPrompts?: boolean 421: description?: string 422: invokingRequestId?: string 423: } 424: export type InProcessRunnerResult = { 425: success: boolean 426: error?: string 427: messages: Message[] 428: } 429: function updateTaskState( 430: taskId: string, 431: updater: (task: InProcessTeammateTaskState) => InProcessTeammateTaskState, 432: setAppState: SetAppStateFn, 433: ): void { 434: setAppState(prev => { 435: const task = prev.tasks[taskId] 436: if (!task || task.type !== 'in_process_teammate') { 437: return prev 438: } 439: const updated = updater(task) 440: if (updated === task) { 441: return prev 442: } 443: return { 444: ...prev, 445: tasks: { 446: ...prev.tasks, 447: [taskId]: updated, 448: }, 449: } 450: }) 451: } 452: async function sendMessageToLeader( 453: from: string, 454: text: string, 455: color: string | undefined, 456: teamName: string, 457: ): Promise<void> { 458: await writeToMailbox( 459: TEAM_LEAD_NAME, 460: { 461: from, 462: text, 463: timestamp: new Date().toISOString(), 464: color, 465: }, 466: teamName, 467: ) 468: } 469: async function sendIdleNotification( 470: agentName: string, 471: agentColor: string | undefined, 472: teamName: string, 473: options?: { 474: idleReason?: 'available' | 'interrupted' | 'failed' 475: summary?: string 476: completedTaskId?: string 477: completedStatus?: 'resolved' | 'blocked' | 'failed' 478: failureReason?: string 479: }, 480: ): Promise<void> { 481: const notification = createIdleNotification(agentName, options) 482: await sendMessageToLeader( 483: agentName, 484: jsonStringify(notification), 485: agentColor, 486: teamName, 487: ) 488: } 489: function findAvailableTask(tasks: Task[]): Task | undefined { 490: const unresolvedTaskIds = new Set( 491: tasks.filter(t => t.status !== 'completed').map(t => t.id), 492: ) 493: return tasks.find(task => { 494: if (task.status !== 'pending') return false 495: if (task.owner) return false 496: return task.blockedBy.every(id => !unresolvedTaskIds.has(id)) 497: }) 498: } 499: function formatTaskAsPrompt(task: Task): string { 500: let prompt = `Complete all open tasks. Start with task #${task.id}: \n\n ${task.subject}` 501: if (task.description) { 502: prompt += `\n\n${task.description}` 503: } 504: return prompt 505: } 506: async function tryClaimNextTask( 507: taskListId: string, 508: agentName: string, 509: ): Promise<string | undefined> { 510: try { 511: const tasks = await listTasks(taskListId) 512: const availableTask = findAvailableTask(tasks) 513: if (!availableTask) { 514: return undefined 515: } 516: const result = await claimTask(taskListId, availableTask.id, agentName) 517: if (!result.success) { 518: logForDebugging( 519: `[inProcessRunner] Failed to claim task #${availableTask.id}: ${result.reason}`, 520: ) 521: return undefined 522: } 523: await updateTask(taskListId, availableTask.id, { status: 'in_progress' }) 524: logForDebugging( 525: `[inProcessRunner] Claimed task #${availableTask.id}: ${availableTask.subject}`, 526: ) 527: return formatTaskAsPrompt(availableTask) 528: } catch (err) { 529: logForDebugging(`[inProcessRunner] Error checking task list: ${err}`) 530: return undefined 531: } 532: } 533: type WaitResult = 534: | { 535: type: 'shutdown_request' 536: request: ReturnType<typeof isShutdownRequest> 537: originalMessage: string 538: } 539: | { 540: type: 'new_message' 541: message: string 542: from: string 543: color?: string 544: summary?: string 545: } 546: | { 547: type: 'aborted' 548: } 549: async function waitForNextPromptOrShutdown( 550: identity: TeammateIdentity, 551: abortController: AbortController, 552: taskId: string, 553: getAppState: () => AppState, 554: setAppState: SetAppStateFn, 555: taskListId: string, 556: ): Promise<WaitResult> { 557: const POLL_INTERVAL_MS = 500 558: logForDebugging( 559: `[inProcessRunner] ${identity.agentName} starting poll loop (abort=${abortController.signal.aborted})`, 560: ) 561: let pollCount = 0 562: while (!abortController.signal.aborted) { 563: const appState = getAppState() 564: const task = appState.tasks[taskId] 565: if ( 566: task && 567: task.type === 'in_process_teammate' && 568: task.pendingUserMessages.length > 0 569: ) { 570: const message = task.pendingUserMessages[0]! 571: setAppState(prev => { 572: const prevTask = prev.tasks[taskId] 573: if (!prevTask || prevTask.type !== 'in_process_teammate') { 574: return prev 575: } 576: return { 577: ...prev, 578: tasks: { 579: ...prev.tasks, 580: [taskId]: { 581: ...prevTask, 582: pendingUserMessages: prevTask.pendingUserMessages.slice(1), 583: }, 584: }, 585: } 586: }) 587: logForDebugging( 588: `[inProcessRunner] ${identity.agentName} found pending user message (poll #${pollCount})`, 589: ) 590: return { 591: type: 'new_message', 592: message, 593: from: 'user', 594: } 595: } 596: if (pollCount > 0) { 597: await sleep(POLL_INTERVAL_MS) 598: } 599: pollCount++ 600: if (abortController.signal.aborted) { 601: logForDebugging( 602: `[inProcessRunner] ${identity.agentName} aborted while waiting (poll #${pollCount})`, 603: ) 604: return { type: 'aborted' } 605: } 606: logForDebugging( 607: `[inProcessRunner] ${identity.agentName} poll #${pollCount}: checking mailbox`, 608: ) 609: try { 610: const allMessages = await readMailbox( 611: identity.agentName, 612: identity.teamName, 613: ) 614: let shutdownIndex = -1 615: let shutdownParsed: ReturnType<typeof isShutdownRequest> = null 616: for (let i = 0; i < allMessages.length; i++) { 617: const m = allMessages[i] 618: if (m && !m.read) { 619: const parsed = isShutdownRequest(m.text) 620: if (parsed) { 621: shutdownIndex = i 622: shutdownParsed = parsed 623: break 624: } 625: } 626: } 627: if (shutdownIndex !== -1) { 628: const msg = allMessages[shutdownIndex]! 629: const skippedUnread = count( 630: allMessages.slice(0, shutdownIndex), 631: m => !m.read, 632: ) 633: logForDebugging( 634: `[inProcessRunner] ${identity.agentName} received shutdown request from ${shutdownParsed?.from} (prioritized over ${skippedUnread} unread messages)`, 635: ) 636: await markMessageAsReadByIndex( 637: identity.agentName, 638: identity.teamName, 639: shutdownIndex, 640: ) 641: return { 642: type: 'shutdown_request', 643: request: shutdownParsed, 644: originalMessage: msg.text, 645: } 646: } 647: let selectedIndex = -1 648: for (let i = 0; i < allMessages.length; i++) { 649: const m = allMessages[i] 650: if (m && !m.read && m.from === TEAM_LEAD_NAME) { 651: selectedIndex = i 652: break 653: } 654: } 655: if (selectedIndex === -1) { 656: selectedIndex = allMessages.findIndex(m => !m.read) 657: } 658: if (selectedIndex !== -1) { 659: const msg = allMessages[selectedIndex] 660: if (msg) { 661: logForDebugging( 662: `[inProcessRunner] ${identity.agentName} received new message from ${msg.from} (index ${selectedIndex})`, 663: ) 664: await markMessageAsReadByIndex( 665: identity.agentName, 666: identity.teamName, 667: selectedIndex, 668: ) 669: return { 670: type: 'new_message', 671: message: msg.text, 672: from: msg.from, 673: color: msg.color, 674: summary: msg.summary, 675: } 676: } 677: } 678: } catch (err) { 679: logForDebugging( 680: `[inProcessRunner] ${identity.agentName} poll error: ${err}`, 681: ) 682: } 683: const taskPrompt = await tryClaimNextTask(taskListId, identity.agentName) 684: if (taskPrompt) { 685: return { 686: type: 'new_message', 687: message: taskPrompt, 688: from: 'task-list', 689: } 690: } 691: } 692: logForDebugging( 693: `[inProcessRunner] ${identity.agentName} exiting poll loop (abort=${abortController.signal.aborted}, polls=${pollCount})`, 694: ) 695: return { type: 'aborted' } 696: } 697: export async function runInProcessTeammate( 698: config: InProcessRunnerConfig, 699: ): Promise<InProcessRunnerResult> { 700: const { 701: identity, 702: taskId, 703: prompt, 704: description, 705: agentDefinition, 706: teammateContext, 707: toolUseContext, 708: abortController, 709: model, 710: systemPrompt, 711: systemPromptMode, 712: allowedTools, 713: allowPermissionPrompts, 714: invokingRequestId, 715: } = config 716: const { setAppState } = toolUseContext 717: logForDebugging( 718: `[inProcessRunner] Starting agent loop for ${identity.agentId}`, 719: ) 720: const agentContext: AgentContext = { 721: agentId: identity.agentId, 722: parentSessionId: identity.parentSessionId, 723: agentName: identity.agentName, 724: teamName: identity.teamName, 725: agentColor: identity.color, 726: planModeRequired: identity.planModeRequired, 727: isTeamLead: false, 728: agentType: 'teammate', 729: invokingRequestId, 730: invocationKind: 'spawn', 731: invocationEmitted: false, 732: } 733: let teammateSystemPrompt: string 734: if (systemPromptMode === 'replace' && systemPrompt) { 735: teammateSystemPrompt = systemPrompt 736: } else { 737: const fullSystemPromptParts = await getSystemPrompt( 738: toolUseContext.options.tools, 739: toolUseContext.options.mainLoopModel, 740: undefined, 741: toolUseContext.options.mcpClients, 742: ) 743: const systemPromptParts = [ 744: ...fullSystemPromptParts, 745: TEAMMATE_SYSTEM_PROMPT_ADDENDUM, 746: ] 747: if (agentDefinition) { 748: const customPrompt = agentDefinition.getSystemPrompt() 749: if (customPrompt) { 750: systemPromptParts.push(`\n# Custom Agent Instructions\n${customPrompt}`) 751: } 752: if (agentDefinition.memory) { 753: logEvent('tengu_agent_memory_loaded', { 754: ...(process.env.USER_TYPE === 'ant' 755: ? { 756: agent_type: 757: agentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 758: } 759: : {}), 760: scope: 761: agentDefinition.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 762: source: 763: 'in-process-teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 764: }) 765: } 766: } 767: if (systemPromptMode === 'append' && systemPrompt) { 768: systemPromptParts.push(systemPrompt) 769: } 770: teammateSystemPrompt = systemPromptParts.join('\n') 771: } 772: const resolvedAgentDefinition: CustomAgentDefinition = { 773: agentType: identity.agentName, 774: whenToUse: `In-process teammate: ${identity.agentName}`, 775: getSystemPrompt: () => teammateSystemPrompt, 776: tools: agentDefinition?.tools 777: ? [ 778: ...new Set([ 779: ...agentDefinition.tools, 780: SEND_MESSAGE_TOOL_NAME, 781: TEAM_CREATE_TOOL_NAME, 782: TEAM_DELETE_TOOL_NAME, 783: TASK_CREATE_TOOL_NAME, 784: TASK_GET_TOOL_NAME, 785: TASK_LIST_TOOL_NAME, 786: TASK_UPDATE_TOOL_NAME, 787: ]), 788: ] 789: : ['*'], 790: source: 'projectSettings', 791: permissionMode: 'default', 792: ...(agentDefinition?.model ? { model: agentDefinition.model } : {}), 793: } 794: const allMessages: Message[] = [] 795: const wrappedInitialPrompt = formatAsTeammateMessage( 796: 'team-lead', 797: prompt, 798: undefined, 799: description, 800: ) 801: let currentPrompt = wrappedInitialPrompt 802: let shouldExit = false 803: await tryClaimNextTask(identity.parentSessionId, identity.agentName) 804: try { 805: updateTaskState( 806: taskId, 807: task => ({ 808: ...task, 809: messages: appendCappedMessage( 810: task.messages, 811: createUserMessage({ content: wrappedInitialPrompt }), 812: ), 813: }), 814: setAppState, 815: ) 816: let teammateReplacementState = toolUseContext.contentReplacementState 817: ? createContentReplacementState() 818: : undefined 819: while (!abortController.signal.aborted && !shouldExit) { 820: logForDebugging( 821: `[inProcessRunner] ${identity.agentId} processing prompt: ${currentPrompt.substring(0, 50)}...`, 822: ) 823: const currentWorkAbortController = createAbortController() 824: updateTaskState( 825: taskId, 826: task => ({ ...task, currentWorkAbortController }), 827: setAppState, 828: ) 829: const userMessage = createUserMessage({ content: currentPrompt }) 830: const promptMessages: Message[] = [userMessage] 831: let contextMessages = allMessages 832: const tokenCount = tokenCountWithEstimation(allMessages) 833: if ( 834: tokenCount > 835: getAutoCompactThreshold(toolUseContext.options.mainLoopModel) 836: ) { 837: logForDebugging( 838: `[inProcessRunner] ${identity.agentId} compacting history (${tokenCount} tokens)`, 839: ) 840: const isolatedContext: ToolUseContext = { 841: ...toolUseContext, 842: readFileState: cloneFileStateCache(toolUseContext.readFileState), 843: onCompactProgress: undefined, 844: setStreamMode: undefined, 845: } 846: const compactedSummary = await compactConversation( 847: allMessages, 848: isolatedContext, 849: { 850: systemPrompt: asSystemPrompt([]), 851: userContext: {}, 852: systemContext: {}, 853: toolUseContext: isolatedContext, 854: forkContextMessages: [], 855: }, 856: true, 857: undefined, 858: true, 859: ) 860: contextMessages = buildPostCompactMessages(compactedSummary) 861: resetMicrocompactState() 862: if (teammateReplacementState) { 863: teammateReplacementState = createContentReplacementState() 864: } 865: allMessages.length = 0 866: allMessages.push(...contextMessages) 867: updateTaskState( 868: taskId, 869: task => ({ ...task, messages: [...contextMessages, userMessage] }), 870: setAppState, 871: ) 872: } 873: const forkContextMessages = 874: contextMessages.length > 0 ? [...contextMessages] : undefined 875: allMessages.push(userMessage) 876: const tracker = createProgressTracker() 877: const resolveActivity = createActivityDescriptionResolver( 878: toolUseContext.options.tools, 879: ) 880: const iterationMessages: Message[] = [] 881: const currentAppState = toolUseContext.getAppState() 882: const currentTask = currentAppState.tasks[taskId] 883: const currentPermissionMode = 884: currentTask && currentTask.type === 'in_process_teammate' 885: ? currentTask.permissionMode 886: : 'default' 887: const iterationAgentDefinition = { 888: ...resolvedAgentDefinition, 889: permissionMode: currentPermissionMode, 890: } 891: let workWasAborted = false 892: await runWithTeammateContext(teammateContext, async () => { 893: return runWithAgentContext(agentContext, async () => { 894: updateTaskState( 895: taskId, 896: task => ({ ...task, status: 'running', isIdle: false }), 897: setAppState, 898: ) 899: for await (const message of runAgent({ 900: agentDefinition: iterationAgentDefinition, 901: promptMessages, 902: toolUseContext, 903: canUseTool: createInProcessCanUseTool( 904: identity, 905: currentWorkAbortController, 906: (waitMs: number) => { 907: updateTaskState( 908: taskId, 909: task => ({ 910: ...task, 911: totalPausedMs: (task.totalPausedMs ?? 0) + waitMs, 912: }), 913: setAppState, 914: ) 915: }, 916: ), 917: isAsync: true, 918: canShowPermissionPrompts: allowPermissionPrompts ?? true, 919: forkContextMessages, 920: querySource: 'agent:custom', 921: override: { abortController: currentWorkAbortController }, 922: model: model as ModelAlias | undefined, 923: preserveToolUseResults: true, 924: availableTools: toolUseContext.options.tools, 925: allowedTools, 926: contentReplacementState: teammateReplacementState, 927: })) { 928: if (abortController.signal.aborted) { 929: logForDebugging( 930: `[inProcessRunner] ${identity.agentId} lifecycle aborted`, 931: ) 932: break 933: } 934: if (currentWorkAbortController.signal.aborted) { 935: logForDebugging( 936: `[inProcessRunner] ${identity.agentId} current work aborted (Escape pressed)`, 937: ) 938: workWasAborted = true 939: break 940: } 941: iterationMessages.push(message) 942: allMessages.push(message) 943: updateProgressFromMessage( 944: tracker, 945: message, 946: resolveActivity, 947: toolUseContext.options.tools, 948: ) 949: const progress = getProgressUpdate(tracker) 950: updateTaskState( 951: taskId, 952: task => { 953: let inProgressToolUseIDs = task.inProgressToolUseIDs 954: if (message.type === 'assistant') { 955: for (const block of message.message.content) { 956: if (block.type === 'tool_use') { 957: inProgressToolUseIDs = new Set([ 958: ...(inProgressToolUseIDs ?? []), 959: block.id, 960: ]) 961: } 962: } 963: } else if (message.type === 'user') { 964: const content = message.message.content 965: if (Array.isArray(content)) { 966: for (const block of content) { 967: if ( 968: typeof block === 'object' && 969: 'type' in block && 970: block.type === 'tool_result' 971: ) { 972: if (inProgressToolUseIDs) { 973: inProgressToolUseIDs = new Set(inProgressToolUseIDs) 974: inProgressToolUseIDs.delete(block.tool_use_id) 975: } 976: } 977: } 978: } 979: } 980: return { 981: ...task, 982: progress, 983: messages: appendCappedMessage(task.messages, message), 984: inProgressToolUseIDs, 985: } 986: }, 987: setAppState, 988: ) 989: } 990: return { success: true, messages: iterationMessages } 991: }) 992: }) 993: updateTaskState( 994: taskId, 995: task => ({ ...task, currentWorkAbortController: undefined }), 996: setAppState, 997: ) 998: if (abortController.signal.aborted) { 999: break 1000: } 1001: if (workWasAborted) { 1002: logForDebugging( 1003: `[inProcessRunner] ${identity.agentId} work interrupted, returning to idle`, 1004: ) 1005: const interruptMessage = createAssistantAPIErrorMessage({ 1006: content: ERROR_MESSAGE_USER_ABORT, 1007: }) 1008: updateTaskState( 1009: taskId, 1010: task => ({ 1011: ...task, 1012: messages: appendCappedMessage(task.messages, interruptMessage), 1013: }), 1014: setAppState, 1015: ) 1016: } 1017: const prevAppState = toolUseContext.getAppState() 1018: const prevTask = prevAppState.tasks[taskId] 1019: const wasAlreadyIdle = 1020: prevTask?.type === 'in_process_teammate' && prevTask.isIdle 1021: updateTaskState( 1022: taskId, 1023: task => { 1024: task.onIdleCallbacks?.forEach(cb => cb()) 1025: return { ...task, isIdle: true, onIdleCallbacks: [] } 1026: }, 1027: setAppState, 1028: ) 1029: if (!wasAlreadyIdle) { 1030: await sendIdleNotification( 1031: identity.agentName, 1032: identity.color, 1033: identity.teamName, 1034: { 1035: idleReason: workWasAborted ? 'interrupted' : 'available', 1036: summary: getLastPeerDmSummary(allMessages), 1037: }, 1038: ) 1039: } else { 1040: logForDebugging( 1041: `[inProcessRunner] Skipping duplicate idle notification for ${identity.agentName}`, 1042: ) 1043: } 1044: logForDebugging( 1045: `[inProcessRunner] ${identity.agentId} finished prompt, waiting for next`, 1046: ) 1047: const waitResult = await waitForNextPromptOrShutdown( 1048: identity, 1049: abortController, 1050: taskId, 1051: toolUseContext.getAppState, 1052: setAppState, 1053: identity.parentSessionId, 1054: ) 1055: switch (waitResult.type) { 1056: case 'shutdown_request': 1057: logForDebugging( 1058: `[inProcessRunner] ${identity.agentId} received shutdown request - passing to model`, 1059: ) 1060: currentPrompt = formatAsTeammateMessage( 1061: waitResult.request?.from || 'team-lead', 1062: waitResult.originalMessage, 1063: ) 1064: appendTeammateMessage( 1065: taskId, 1066: createUserMessage({ content: currentPrompt }), 1067: setAppState, 1068: ) 1069: break 1070: case 'new_message': 1071: logForDebugging( 1072: `[inProcessRunner] ${identity.agentId} received new message from ${waitResult.from}`, 1073: ) 1074: if (waitResult.from === 'user') { 1075: currentPrompt = waitResult.message 1076: } else { 1077: currentPrompt = formatAsTeammateMessage( 1078: waitResult.from, 1079: waitResult.message, 1080: waitResult.color, 1081: waitResult.summary, 1082: ) 1083: appendTeammateMessage( 1084: taskId, 1085: createUserMessage({ content: currentPrompt }), 1086: setAppState, 1087: ) 1088: } 1089: break 1090: case 'aborted': 1091: logForDebugging( 1092: `[inProcessRunner] ${identity.agentId} aborted while waiting`, 1093: ) 1094: shouldExit = true 1095: break 1096: } 1097: } 1098: let alreadyTerminal = false 1099: let toolUseId: string | undefined 1100: updateTaskState( 1101: taskId, 1102: task => { 1103: if (task.status !== 'running') { 1104: alreadyTerminal = true 1105: return task 1106: } 1107: toolUseId = task.toolUseId 1108: task.onIdleCallbacks?.forEach(cb => cb()) 1109: task.unregisterCleanup?.() 1110: return { 1111: ...task, 1112: status: 'completed' as const, 1113: notified: true, 1114: endTime: Date.now(), 1115: messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, 1116: pendingUserMessages: [], 1117: inProgressToolUseIDs: undefined, 1118: abortController: undefined, 1119: unregisterCleanup: undefined, 1120: currentWorkAbortController: undefined, 1121: onIdleCallbacks: [], 1122: } 1123: }, 1124: setAppState, 1125: ) 1126: void evictTaskOutput(taskId) 1127: evictTerminalTask(taskId, setAppState) 1128: if (!alreadyTerminal) { 1129: emitTaskTerminatedSdk(taskId, 'completed', { 1130: toolUseId, 1131: summary: identity.agentId, 1132: }) 1133: } 1134: unregisterPerfettoAgent(identity.agentId) 1135: return { success: true, messages: allMessages } 1136: } catch (error) { 1137: const errorMessage = 1138: error instanceof Error ? error.message : 'Unknown error' 1139: logForDebugging( 1140: `[inProcessRunner] Agent ${identity.agentId} failed: ${errorMessage}`, 1141: ) 1142: let alreadyTerminal = false 1143: let toolUseId: string | undefined 1144: updateTaskState( 1145: taskId, 1146: task => { 1147: if (task.status !== 'running') { 1148: alreadyTerminal = true 1149: return task 1150: } 1151: toolUseId = task.toolUseId 1152: task.onIdleCallbacks?.forEach(cb => cb()) 1153: task.unregisterCleanup?.() 1154: return { 1155: ...task, 1156: status: 'failed' as const, 1157: notified: true, 1158: error: errorMessage, 1159: isIdle: true, 1160: endTime: Date.now(), 1161: onIdleCallbacks: [], 1162: messages: task.messages?.length ? [task.messages.at(-1)!] : undefined, 1163: pendingUserMessages: [], 1164: inProgressToolUseIDs: undefined, 1165: abortController: undefined, 1166: unregisterCleanup: undefined, 1167: currentWorkAbortController: undefined, 1168: } 1169: }, 1170: setAppState, 1171: ) 1172: void evictTaskOutput(taskId) 1173: evictTerminalTask(taskId, setAppState) 1174: if (!alreadyTerminal) { 1175: emitTaskTerminatedSdk(taskId, 'failed', { 1176: toolUseId, 1177: summary: identity.agentId, 1178: }) 1179: } 1180: await sendIdleNotification( 1181: identity.agentName, 1182: identity.color, 1183: identity.teamName, 1184: { 1185: idleReason: 'failed', 1186: completedStatus: 'failed', 1187: failureReason: errorMessage, 1188: }, 1189: ) 1190: unregisterPerfettoAgent(identity.agentId) 1191: return { 1192: success: false, 1193: error: errorMessage, 1194: messages: allMessages, 1195: } 1196: } 1197: } 1198: export function startInProcessTeammate(config: InProcessRunnerConfig): void { 1199: const agentId = config.identity.agentId 1200: void runInProcessTeammate(config).catch(error => { 1201: logForDebugging(`[inProcessRunner] Unhandled error in ${agentId}: ${error}`) 1202: }) 1203: }

File: src/utils/swarm/It2SetupPrompt.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import React, { useCallback, useEffect, useState } from 'react'; 3: import { type OptionWithDescription, Select } from '../../components/CustomSelect/index.js'; 4: import { Pane } from '../../components/design-system/Pane.js'; 5: import { Spinner } from '../../components/Spinner.js'; 6: import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; 7: import { Box, Text, useInput } from '../../ink.js'; 8: import { useKeybinding } from '../../keybindings/useKeybinding.js'; 9: import { detectPythonPackageManager, getPythonApiInstructions, installIt2, markIt2SetupComplete, type PythonPackageManager, setPreferTmuxOverIterm2, verifyIt2Setup } from './backends/it2Setup.js'; 10: type SetupStep = 'initial' | 'installing' | 'install-failed' | 'verify-api' | 'api-instructions' | 'verifying' | 'success' | 'failed'; 11: type Props = { 12: onDone: (result: 'installed' | 'use-tmux' | 'cancelled') => void; 13: tmuxAvailable: boolean; 14: }; 15: export function It2SetupPrompt(t0) { 16: const $ = _c(44); 17: const { 18: onDone, 19: tmuxAvailable 20: } = t0; 21: const [step, setStep] = useState("initial"); 22: const [packageManager, setPackageManager] = useState(null); 23: const [error, setError] = useState(null); 24: const exitState = useExitOnCtrlCDWithKeybindings(); 25: let t1; 26: let t2; 27: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 28: t1 = () => { 29: detectPythonPackageManager().then(pm => { 30: setPackageManager(pm); 31: }); 32: }; 33: t2 = []; 34: $[0] = t1; 35: $[1] = t2; 36: } else { 37: t1 = $[0]; 38: t2 = $[1]; 39: } 40: useEffect(t1, t2); 41: let t3; 42: if ($[2] !== onDone) { 43: t3 = () => { 44: onDone("cancelled"); 45: }; 46: $[2] = onDone; 47: $[3] = t3; 48: } else { 49: t3 = $[3]; 50: } 51: const handleCancel = t3; 52: const t4 = step !== "installing" && step !== "verifying"; 53: let t5; 54: if ($[4] !== t4) { 55: t5 = { 56: context: "Confirmation", 57: isActive: t4 58: }; 59: $[4] = t4; 60: $[5] = t5; 61: } else { 62: t5 = $[5]; 63: } 64: useKeybinding("confirm:no", handleCancel, t5); 65: let t6; 66: if ($[6] !== onDone || $[7] !== step) { 67: t6 = (_input, key) => { 68: if (step === "api-instructions" && key.return) { 69: setStep("verifying"); 70: verifyIt2Setup().then(result => { 71: if (result.success) { 72: markIt2SetupComplete(); 73: setStep("success"); 74: setTimeout(onDone, 1500, "installed" as const); 75: } else { 76: setError(result.error || "Verification failed"); 77: setStep("failed"); 78: } 79: }); 80: } 81: }; 82: $[6] = onDone; 83: $[7] = step; 84: $[8] = t6; 85: } else { 86: t6 = $[8]; 87: } 88: useInput(t6); 89: let t7; 90: if ($[9] !== packageManager) { 91: t7 = async function handleInstall() { 92: if (!packageManager) { 93: setError("No Python package manager found (uvx, pipx, or pip)"); 94: setStep("failed"); 95: return; 96: } 97: setStep("installing"); 98: const result_0 = await installIt2(packageManager); 99: if (result_0.success) { 100: setStep("api-instructions"); 101: } else { 102: setError(result_0.error || "Installation failed"); 103: setStep("install-failed"); 104: } 105: }; 106: $[9] = packageManager; 107: $[10] = t7; 108: } else { 109: t7 = $[10]; 110: } 111: const handleInstall = t7; 112: let t8; 113: if ($[11] !== onDone) { 114: t8 = function handleUseTmux() { 115: setPreferTmuxOverIterm2(true); 116: onDone("use-tmux"); 117: }; 118: $[11] = onDone; 119: $[12] = t8; 120: } else { 121: t8 = $[12]; 122: } 123: const handleUseTmux = t8; 124: let T0; 125: let T1; 126: let t10; 127: let t11; 128: let t12; 129: let t13; 130: let t14; 131: let t9; 132: if ($[13] !== error || $[14] !== handleInstall || $[15] !== handleUseTmux || $[16] !== onDone || $[17] !== packageManager || $[18] !== step || $[19] !== tmuxAvailable) { 133: const renderContent = () => { 134: switch (step) { 135: case "initial": 136: { 137: return renderInitialPrompt(); 138: } 139: case "installing": 140: { 141: return renderInstalling(); 142: } 143: case "install-failed": 144: { 145: return renderInstallFailed(); 146: } 147: case "api-instructions": 148: { 149: return renderApiInstructions(); 150: } 151: case "verifying": 152: { 153: return renderVerifying(); 154: } 155: case "success": 156: { 157: return renderSuccess(); 158: } 159: case "failed": 160: { 161: return renderFailed(); 162: } 163: default: 164: { 165: return null; 166: } 167: } 168: }; 169: function renderInitialPrompt() { 170: const options = [{ 171: label: "Install it2 now", 172: value: "install", 173: description: packageManager ? `Uses ${packageManager} to install the it2 CLI tool` : "Requires Python (uvx, pipx, or pip)" 174: }]; 175: if (tmuxAvailable) { 176: options.push({ 177: label: "Use tmux instead", 178: value: "tmux", 179: description: "Opens teammates in a separate tmux session" 180: }); 181: } 182: options.push({ 183: label: "Cancel", 184: value: "cancel", 185: description: "Skip teammate spawning for now" 186: }); 187: return <Box flexDirection="column" gap={1}><Text>To use native iTerm2 split panes for teammates, you need the{" "}<Text bold={true}>it2</Text> CLI tool.</Text><Text dimColor={true}>This enables teammates to appear as split panes within your current window.</Text><Box marginTop={1}><Select options={options} onChange={value => { 188: bb61: switch (value) { 189: case "install": 190: { 191: handleInstall(); 192: break bb61; 193: } 194: case "tmux": 195: { 196: handleUseTmux(); 197: break bb61; 198: } 199: case "cancel": 200: { 201: onDone("cancelled"); 202: } 203: } 204: }} onCancel={() => onDone("cancelled")} /></Box></Box>; 205: } 206: function renderInstalling() { 207: return <Box flexDirection="column" gap={1}><Box><Spinner /><Text> Installing it2 using {packageManager}…</Text></Box><Text dimColor={true}>This may take a moment.</Text></Box>; 208: } 209: function renderInstallFailed() { 210: const options_0 = [{ 211: label: "Try again", 212: value: "retry", 213: description: "Retry the installation" 214: }]; 215: if (tmuxAvailable) { 216: options_0.push({ 217: label: "Use tmux instead", 218: value: "tmux", 219: description: "Falls back to tmux for teammate panes" 220: }); 221: } 222: options_0.push({ 223: label: "Cancel", 224: value: "cancel", 225: description: "Skip teammate spawning for now" 226: }); 227: return <Box flexDirection="column" gap={1}><Text color="error">Installation failed</Text>{error && <Text dimColor={true}>{error}</Text>}<Text dimColor={true}>You can try installing manually:{" "}{packageManager === "uvx" ? "uv tool install it2" : packageManager === "pipx" ? "pipx install it2" : "pip install --user it2"}</Text><Box marginTop={1}><Select options={options_0} onChange={value_0 => { 228: bb89: switch (value_0) { 229: case "retry": 230: { 231: handleInstall(); 232: break bb89; 233: } 234: case "tmux": 235: { 236: handleUseTmux(); 237: break bb89; 238: } 239: case "cancel": 240: { 241: onDone("cancelled"); 242: } 243: } 244: }} onCancel={() => onDone("cancelled")} /></Box></Box>; 245: } 246: function renderApiInstructions() { 247: const instructions = getPythonApiInstructions(); 248: return <Box flexDirection="column" gap={1}><Text color="success">✓ it2 installed successfully</Text><Box flexDirection="column" marginTop={1}>{instructions.map(_temp)}</Box><Box marginTop={1}><Text dimColor={true}>Press Enter when ready to verify…</Text></Box></Box>; 249: } 250: function renderVerifying() { 251: return <Box><Spinner /><Text> Verifying it2 can communicate with iTerm2…</Text></Box>; 252: } 253: function renderSuccess() { 254: return <Box flexDirection="column"><Text color="success">✓ iTerm2 split pane support is ready</Text><Text dimColor={true}>Teammates will now appear as split panes.</Text></Box>; 255: } 256: function renderFailed() { 257: const options_1 = [{ 258: label: "Try again", 259: value: "retry", 260: description: "Verify the connection again" 261: }]; 262: if (tmuxAvailable) { 263: options_1.push({ 264: label: "Use tmux instead", 265: value: "tmux", 266: description: "Falls back to tmux for teammate panes" 267: }); 268: } 269: options_1.push({ 270: label: "Cancel", 271: value: "cancel", 272: description: "Skip teammate spawning for now" 273: }); 274: return <Box flexDirection="column" gap={1}><Text color="error">Verification failed</Text>{error && <Text dimColor={true}>{error}</Text>}<Text>Make sure:</Text><Box flexDirection="column" paddingLeft={2}><Text>· Python API is enabled in iTerm2 preferences</Text><Text>· You may need to restart iTerm2 after enabling</Text></Box><Box marginTop={1}><Select options={options_1} onChange={value_1 => { 275: bb115: switch (value_1) { 276: case "retry": 277: { 278: setStep("verifying"); 279: verifyIt2Setup().then(result_1 => { 280: if (result_1.success) { 281: markIt2SetupComplete(); 282: setStep("success"); 283: setTimeout(onDone, 1500, "installed" as const); 284: } else { 285: setError(result_1.error || "Verification failed"); 286: setStep("failed"); 287: } 288: }); 289: break bb115; 290: } 291: case "tmux": 292: { 293: handleUseTmux(); 294: break bb115; 295: } 296: case "cancel": 297: { 298: onDone("cancelled"); 299: } 300: } 301: }} onCancel={() => onDone("cancelled")} /></Box></Box>; 302: } 303: T1 = Pane; 304: t14 = "permission"; 305: T0 = Box; 306: t9 = "column"; 307: t10 = 1; 308: t11 = 1; 309: if ($[28] === Symbol.for("react.memo_cache_sentinel")) { 310: t12 = <Text bold={true} color="permission">iTerm2 Split Pane Setup</Text>; 311: $[28] = t12; 312: } else { 313: t12 = $[28]; 314: } 315: t13 = renderContent(); 316: $[13] = error; 317: $[14] = handleInstall; 318: $[15] = handleUseTmux; 319: $[16] = onDone; 320: $[17] = packageManager; 321: $[18] = step; 322: $[19] = tmuxAvailable; 323: $[20] = T0; 324: $[21] = T1; 325: $[22] = t10; 326: $[23] = t11; 327: $[24] = t12; 328: $[25] = t13; 329: $[26] = t14; 330: $[27] = t9; 331: } else { 332: T0 = $[20]; 333: T1 = $[21]; 334: t10 = $[22]; 335: t11 = $[23]; 336: t12 = $[24]; 337: t13 = $[25]; 338: t14 = $[26]; 339: t9 = $[27]; 340: } 341: let t15; 342: if ($[29] !== exitState || $[30] !== step) { 343: t15 = step !== "installing" && step !== "verifying" && step !== "success" && <Text dimColor={true} italic={true}>{exitState.pending ? <>Press {exitState.keyName} again to exit</> : <>Esc to cancel</>}</Text>; 344: $[29] = exitState; 345: $[30] = step; 346: $[31] = t15; 347: } else { 348: t15 = $[31]; 349: } 350: let t16; 351: if ($[32] !== T0 || $[33] !== t10 || $[34] !== t11 || $[35] !== t12 || $[36] !== t13 || $[37] !== t15 || $[38] !== t9) { 352: t16 = <T0 flexDirection={t9} gap={t10} paddingBottom={t11}>{t12}{t13}{t15}</T0>; 353: $[32] = T0; 354: $[33] = t10; 355: $[34] = t11; 356: $[35] = t12; 357: $[36] = t13; 358: $[37] = t15; 359: $[38] = t9; 360: $[39] = t16; 361: } else { 362: t16 = $[39]; 363: } 364: let t17; 365: if ($[40] !== T1 || $[41] !== t14 || $[42] !== t16) { 366: t17 = <T1 color={t14}>{t16}</T1>; 367: $[40] = T1; 368: $[41] = t14; 369: $[42] = t16; 370: $[43] = t17; 371: } else { 372: t17 = $[43]; 373: } 374: return t17; 375: } 376: function _temp(line, i) { 377: return <Text key={i}>{line}</Text>; 378: }

File: src/utils/swarm/leaderPermissionBridge.ts

typescript 1: import type { ToolUseConfirm } from '../../components/permissions/PermissionRequest.js' 2: import type { ToolPermissionContext } from '../../Tool.js' 3: export type SetToolUseConfirmQueueFn = ( 4: updater: (prev: ToolUseConfirm[]) => ToolUseConfirm[], 5: ) => void 6: export type SetToolPermissionContextFn = ( 7: context: ToolPermissionContext, 8: options?: { preserveMode?: boolean }, 9: ) => void 10: let registeredSetter: SetToolUseConfirmQueueFn | null = null 11: let registeredPermissionContextSetter: SetToolPermissionContextFn | null = null 12: export function registerLeaderToolUseConfirmQueue( 13: setter: SetToolUseConfirmQueueFn, 14: ): void { 15: registeredSetter = setter 16: } 17: export function getLeaderToolUseConfirmQueue(): SetToolUseConfirmQueueFn | null { 18: return registeredSetter 19: } 20: export function unregisterLeaderToolUseConfirmQueue(): void { 21: registeredSetter = null 22: } 23: export function registerLeaderSetToolPermissionContext( 24: setter: SetToolPermissionContextFn, 25: ): void { 26: registeredPermissionContextSetter = setter 27: } 28: export function getLeaderSetToolPermissionContext(): SetToolPermissionContextFn | null { 29: return registeredPermissionContextSetter 30: } 31: export function unregisterLeaderSetToolPermissionContext(): void { 32: registeredPermissionContextSetter = null 33: }

File: src/utils/swarm/permissionSync.ts

typescript 1: import { mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 2: import { join } from 'path' 3: import { z } from 'zod/v4' 4: import { logForDebugging } from '../debug.js' 5: import { getErrnoCode } from '../errors.js' 6: import { lazySchema } from '../lazySchema.js' 7: import * as lockfile from '../lockfile.js' 8: import { logError } from '../log.js' 9: import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' 10: import { jsonParse, jsonStringify } from '../slowOperations.js' 11: import { 12: getAgentId, 13: getAgentName, 14: getTeammateColor, 15: getTeamName, 16: } from '../teammate.js' 17: import { 18: createPermissionRequestMessage, 19: createPermissionResponseMessage, 20: createSandboxPermissionRequestMessage, 21: createSandboxPermissionResponseMessage, 22: writeToMailbox, 23: } from '../teammateMailbox.js' 24: import { getTeamDir, readTeamFileAsync } from './teamHelpers.js' 25: export const SwarmPermissionRequestSchema = lazySchema(() => 26: z.object({ 27: id: z.string(), 28: workerId: z.string(), 29: workerName: z.string(), 30: workerColor: z.string().optional(), 31: teamName: z.string(), 32: toolName: z.string(), 33: toolUseId: z.string(), 34: description: z.string(), 35: input: z.record(z.string(), z.unknown()), 36: permissionSuggestions: z.array(z.unknown()), 37: status: z.enum(['pending', 'approved', 'rejected']), 38: resolvedBy: z.enum(['worker', 'leader']).optional(), 39: resolvedAt: z.number().optional(), 40: feedback: z.string().optional(), 41: updatedInput: z.record(z.string(), z.unknown()).optional(), 42: permissionUpdates: z.array(z.unknown()).optional(), 43: createdAt: z.number(), 44: }), 45: ) 46: export type SwarmPermissionRequest = z.infer< 47: ReturnType<typeof SwarmPermissionRequestSchema> 48: > 49: export type PermissionResolution = { 50: decision: 'approved' | 'rejected' 51: resolvedBy: 'worker' | 'leader' 52: feedback?: string 53: updatedInput?: Record<string, unknown> 54: permissionUpdates?: PermissionUpdate[] 55: } 56: export function getPermissionDir(teamName: string): string { 57: return join(getTeamDir(teamName), 'permissions') 58: } 59: function getPendingDir(teamName: string): string { 60: return join(getPermissionDir(teamName), 'pending') 61: } 62: function getResolvedDir(teamName: string): string { 63: return join(getPermissionDir(teamName), 'resolved') 64: } 65: async function ensurePermissionDirsAsync(teamName: string): Promise<void> { 66: const permDir = getPermissionDir(teamName) 67: const pendingDir = getPendingDir(teamName) 68: const resolvedDir = getResolvedDir(teamName) 69: for (const dir of [permDir, pendingDir, resolvedDir]) { 70: await mkdir(dir, { recursive: true }) 71: } 72: } 73: function getPendingRequestPath(teamName: string, requestId: string): string { 74: return join(getPendingDir(teamName), `${requestId}.json`) 75: } 76: function getResolvedRequestPath(teamName: string, requestId: string): string { 77: return join(getResolvedDir(teamName), `${requestId}.json`) 78: } 79: export function generateRequestId(): string { 80: return `perm-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` 81: } 82: export function createPermissionRequest(params: { 83: toolName: string 84: toolUseId: string 85: input: Record<string, unknown> 86: description: string 87: permissionSuggestions?: unknown[] 88: teamName?: string 89: workerId?: string 90: workerName?: string 91: workerColor?: string 92: }): SwarmPermissionRequest { 93: const teamName = params.teamName || getTeamName() 94: const workerId = params.workerId || getAgentId() 95: const workerName = params.workerName || getAgentName() 96: const workerColor = params.workerColor || getTeammateColor() 97: if (!teamName) { 98: throw new Error('Team name is required for permission requests') 99: } 100: if (!workerId) { 101: throw new Error('Worker ID is required for permission requests') 102: } 103: if (!workerName) { 104: throw new Error('Worker name is required for permission requests') 105: } 106: return { 107: id: generateRequestId(), 108: workerId, 109: workerName, 110: workerColor, 111: teamName, 112: toolName: params.toolName, 113: toolUseId: params.toolUseId, 114: description: params.description, 115: input: params.input, 116: permissionSuggestions: params.permissionSuggestions || [], 117: status: 'pending', 118: createdAt: Date.now(), 119: } 120: } 121: export async function writePermissionRequest( 122: request: SwarmPermissionRequest, 123: ): Promise<SwarmPermissionRequest> { 124: await ensurePermissionDirsAsync(request.teamName) 125: const pendingPath = getPendingRequestPath(request.teamName, request.id) 126: const lockDir = getPendingDir(request.teamName) 127: const lockFilePath = join(lockDir, '.lock') 128: await writeFile(lockFilePath, '', 'utf-8') 129: let release: (() => Promise<void>) | undefined 130: try { 131: release = await lockfile.lock(lockFilePath) 132: await writeFile(pendingPath, jsonStringify(request, null, 2), 'utf-8') 133: logForDebugging( 134: `[PermissionSync] Wrote pending request ${request.id} from ${request.workerName} for ${request.toolName}`, 135: ) 136: return request 137: } catch (error) { 138: logForDebugging( 139: `[PermissionSync] Failed to write permission request: ${error}`, 140: ) 141: logError(error) 142: throw error 143: } finally { 144: if (release) { 145: await release() 146: } 147: } 148: } 149: export async function readPendingPermissions( 150: teamName?: string, 151: ): Promise<SwarmPermissionRequest[]> { 152: const team = teamName || getTeamName() 153: if (!team) { 154: logForDebugging('[PermissionSync] No team name available') 155: return [] 156: } 157: const pendingDir = getPendingDir(team) 158: let files: string[] 159: try { 160: files = await readdir(pendingDir) 161: } catch (e: unknown) { 162: const code = getErrnoCode(e) 163: if (code === 'ENOENT') { 164: return [] 165: } 166: logForDebugging(`[PermissionSync] Failed to read pending requests: ${e}`) 167: logError(e) 168: return [] 169: } 170: const jsonFiles = files.filter(f => f.endsWith('.json') && f !== '.lock') 171: const results = await Promise.all( 172: jsonFiles.map(async file => { 173: const filePath = join(pendingDir, file) 174: try { 175: const content = await readFile(filePath, 'utf-8') 176: const parsed = SwarmPermissionRequestSchema().safeParse( 177: jsonParse(content), 178: ) 179: if (parsed.success) { 180: return parsed.data 181: } 182: logForDebugging( 183: `[PermissionSync] Invalid request file ${file}: ${parsed.error.message}`, 184: ) 185: return null 186: } catch (err) { 187: logForDebugging( 188: `[PermissionSync] Failed to read request file ${file}: ${err}`, 189: ) 190: return null 191: } 192: }), 193: ) 194: const requests = results.filter(r => r !== null) 195: requests.sort((a, b) => a.createdAt - b.createdAt) 196: return requests 197: } 198: export async function readResolvedPermission( 199: requestId: string, 200: teamName?: string, 201: ): Promise<SwarmPermissionRequest | null> { 202: const team = teamName || getTeamName() 203: if (!team) { 204: return null 205: } 206: const resolvedPath = getResolvedRequestPath(team, requestId) 207: try { 208: const content = await readFile(resolvedPath, 'utf-8') 209: const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content)) 210: if (parsed.success) { 211: return parsed.data 212: } 213: logForDebugging( 214: `[PermissionSync] Invalid resolved request ${requestId}: ${parsed.error.message}`, 215: ) 216: return null 217: } catch (e: unknown) { 218: const code = getErrnoCode(e) 219: if (code === 'ENOENT') { 220: return null 221: } 222: logForDebugging( 223: `[PermissionSync] Failed to read resolved request ${requestId}: ${e}`, 224: ) 225: logError(e) 226: return null 227: } 228: } 229: export async function resolvePermission( 230: requestId: string, 231: resolution: PermissionResolution, 232: teamName?: string, 233: ): Promise<boolean> { 234: const team = teamName || getTeamName() 235: if (!team) { 236: logForDebugging('[PermissionSync] No team name available') 237: return false 238: } 239: await ensurePermissionDirsAsync(team) 240: const pendingPath = getPendingRequestPath(team, requestId) 241: const resolvedPath = getResolvedRequestPath(team, requestId) 242: const lockFilePath = join(getPendingDir(team), '.lock') 243: await writeFile(lockFilePath, '', 'utf-8') 244: let release: (() => Promise<void>) | undefined 245: try { 246: release = await lockfile.lock(lockFilePath) 247: let content: string 248: try { 249: content = await readFile(pendingPath, 'utf-8') 250: } catch (e: unknown) { 251: const code = getErrnoCode(e) 252: if (code === 'ENOENT') { 253: logForDebugging( 254: `[PermissionSync] Pending request not found: ${requestId}`, 255: ) 256: return false 257: } 258: throw e 259: } 260: const parsed = SwarmPermissionRequestSchema().safeParse(jsonParse(content)) 261: if (!parsed.success) { 262: logForDebugging( 263: `[PermissionSync] Invalid pending request ${requestId}: ${parsed.error.message}`, 264: ) 265: return false 266: } 267: const request = parsed.data 268: const resolvedRequest: SwarmPermissionRequest = { 269: ...request, 270: status: resolution.decision === 'approved' ? 'approved' : 'rejected', 271: resolvedBy: resolution.resolvedBy, 272: resolvedAt: Date.now(), 273: feedback: resolution.feedback, 274: updatedInput: resolution.updatedInput, 275: permissionUpdates: resolution.permissionUpdates, 276: } 277: await writeFile( 278: resolvedPath, 279: jsonStringify(resolvedRequest, null, 2), 280: 'utf-8', 281: ) 282: await unlink(pendingPath) 283: logForDebugging( 284: `[PermissionSync] Resolved request ${requestId} with ${resolution.decision}`, 285: ) 286: return true 287: } catch (error) { 288: logForDebugging(`[PermissionSync] Failed to resolve request: ${error}`) 289: logError(error) 290: return false 291: } finally { 292: if (release) { 293: await release() 294: } 295: } 296: } 297: export async function cleanupOldResolutions( 298: teamName?: string, 299: maxAgeMs = 3600000, 300: ): Promise<number> { 301: const team = teamName || getTeamName() 302: if (!team) { 303: return 0 304: } 305: const resolvedDir = getResolvedDir(team) 306: let files: string[] 307: try { 308: files = await readdir(resolvedDir) 309: } catch (e: unknown) { 310: const code = getErrnoCode(e) 311: if (code === 'ENOENT') { 312: return 0 313: } 314: logForDebugging(`[PermissionSync] Failed to cleanup resolutions: ${e}`) 315: logError(e) 316: return 0 317: } 318: const now = Date.now() 319: const jsonFiles = files.filter(f => f.endsWith('.json')) 320: const cleanupResults = await Promise.all( 321: jsonFiles.map(async file => { 322: const filePath = join(resolvedDir, file) 323: try { 324: const content = await readFile(filePath, 'utf-8') 325: const request = jsonParse(content) as SwarmPermissionRequest 326: const resolvedAt = request.resolvedAt || request.createdAt 327: if (now - resolvedAt >= maxAgeMs) { 328: await unlink(filePath) 329: logForDebugging(`[PermissionSync] Cleaned up old resolution: ${file}`) 330: return 1 331: } 332: return 0 333: } catch { 334: try { 335: await unlink(filePath) 336: return 1 337: } catch { 338: return 0 339: } 340: } 341: }), 342: ) 343: const cleanedCount = cleanupResults.reduce<number>((sum, n) => sum + n, 0) 344: if (cleanedCount > 0) { 345: logForDebugging( 346: `[PermissionSync] Cleaned up ${cleanedCount} old resolutions`, 347: ) 348: } 349: return cleanedCount 350: } 351: export type PermissionResponse = { 352: requestId: string 353: decision: 'approved' | 'denied' 354: timestamp: string 355: feedback?: string 356: updatedInput?: Record<string, unknown> 357: permissionUpdates?: unknown[] 358: } 359: export async function pollForResponse( 360: requestId: string, 361: _agentName?: string, 362: teamName?: string, 363: ): Promise<PermissionResponse | null> { 364: const resolved = await readResolvedPermission(requestId, teamName) 365: if (!resolved) { 366: return null 367: } 368: return { 369: requestId: resolved.id, 370: decision: resolved.status === 'approved' ? 'approved' : 'denied', 371: timestamp: resolved.resolvedAt 372: ? new Date(resolved.resolvedAt).toISOString() 373: : new Date(resolved.createdAt).toISOString(), 374: feedback: resolved.feedback, 375: updatedInput: resolved.updatedInput, 376: permissionUpdates: resolved.permissionUpdates, 377: } 378: } 379: export async function removeWorkerResponse( 380: requestId: string, 381: _agentName?: string, 382: teamName?: string, 383: ): Promise<void> { 384: await deleteResolvedPermission(requestId, teamName) 385: } 386: export function isTeamLeader(teamName?: string): boolean { 387: const team = teamName || getTeamName() 388: if (!team) { 389: return false 390: } 391: const agentId = getAgentId() 392: return !agentId || agentId === 'team-lead' 393: } 394: export function isSwarmWorker(): boolean { 395: const teamName = getTeamName() 396: const agentId = getAgentId() 397: return !!teamName && !!agentId && !isTeamLeader() 398: } 399: export async function deleteResolvedPermission( 400: requestId: string, 401: teamName?: string, 402: ): Promise<boolean> { 403: const team = teamName || getTeamName() 404: if (!team) { 405: return false 406: } 407: const resolvedPath = getResolvedRequestPath(team, requestId) 408: try { 409: await unlink(resolvedPath) 410: logForDebugging( 411: `[PermissionSync] Deleted resolved permission: ${requestId}`, 412: ) 413: return true 414: } catch (e: unknown) { 415: const code = getErrnoCode(e) 416: if (code === 'ENOENT') { 417: return false 418: } 419: logForDebugging( 420: `[PermissionSync] Failed to delete resolved permission: ${e}`, 421: ) 422: logError(e) 423: return false 424: } 425: } 426: export const submitPermissionRequest = writePermissionRequest 427: export async function getLeaderName(teamName?: string): Promise<string | null> { 428: const team = teamName || getTeamName() 429: if (!team) { 430: return null 431: } 432: const teamFile = await readTeamFileAsync(team) 433: if (!teamFile) { 434: logForDebugging(`[PermissionSync] Team file not found for team: ${team}`) 435: return null 436: } 437: const leadMember = teamFile.members.find( 438: m => m.agentId === teamFile.leadAgentId, 439: ) 440: return leadMember?.name || 'team-lead' 441: } 442: export async function sendPermissionRequestViaMailbox( 443: request: SwarmPermissionRequest, 444: ): Promise<boolean> { 445: const leaderName = await getLeaderName(request.teamName) 446: if (!leaderName) { 447: logForDebugging( 448: `[PermissionSync] Cannot send permission request: leader name not found`, 449: ) 450: return false 451: } 452: try { 453: const message = createPermissionRequestMessage({ 454: request_id: request.id, 455: agent_id: request.workerName, 456: tool_name: request.toolName, 457: tool_use_id: request.toolUseId, 458: description: request.description, 459: input: request.input, 460: permission_suggestions: request.permissionSuggestions, 461: }) 462: await writeToMailbox( 463: leaderName, 464: { 465: from: request.workerName, 466: text: jsonStringify(message), 467: timestamp: new Date().toISOString(), 468: color: request.workerColor, 469: }, 470: request.teamName, 471: ) 472: logForDebugging( 473: `[PermissionSync] Sent permission request ${request.id} to leader ${leaderName} via mailbox`, 474: ) 475: return true 476: } catch (error) { 477: logForDebugging( 478: `[PermissionSync] Failed to send permission request via mailbox: ${error}`, 479: ) 480: logError(error) 481: return false 482: } 483: } 484: export async function sendPermissionResponseViaMailbox( 485: workerName: string, 486: resolution: PermissionResolution, 487: requestId: string, 488: teamName?: string, 489: ): Promise<boolean> { 490: const team = teamName || getTeamName() 491: if (!team) { 492: logForDebugging( 493: `[PermissionSync] Cannot send permission response: team name not found`, 494: ) 495: return false 496: } 497: try { 498: const message = createPermissionResponseMessage({ 499: request_id: requestId, 500: subtype: resolution.decision === 'approved' ? 'success' : 'error', 501: error: resolution.feedback, 502: updated_input: resolution.updatedInput, 503: permission_updates: resolution.permissionUpdates, 504: }) 505: const senderName = getAgentName() || 'team-lead' 506: await writeToMailbox( 507: workerName, 508: { 509: from: senderName, 510: text: jsonStringify(message), 511: timestamp: new Date().toISOString(), 512: }, 513: team, 514: ) 515: logForDebugging( 516: `[PermissionSync] Sent permission response for ${requestId} to worker ${workerName} via mailbox`, 517: ) 518: return true 519: } catch (error) { 520: logForDebugging( 521: `[PermissionSync] Failed to send permission response via mailbox: ${error}`, 522: ) 523: logError(error) 524: return false 525: } 526: } 527: export function generateSandboxRequestId(): string { 528: return `sandbox-${Date.now()}-${Math.random().toString(36).substring(2, 9)}` 529: } 530: export async function sendSandboxPermissionRequestViaMailbox( 531: host: string, 532: requestId: string, 533: teamName?: string, 534: ): Promise<boolean> { 535: const team = teamName || getTeamName() 536: if (!team) { 537: logForDebugging( 538: `[PermissionSync] Cannot send sandbox permission request: team name not found`, 539: ) 540: return false 541: } 542: const leaderName = await getLeaderName(team) 543: if (!leaderName) { 544: logForDebugging( 545: `[PermissionSync] Cannot send sandbox permission request: leader name not found`, 546: ) 547: return false 548: } 549: const workerId = getAgentId() 550: const workerName = getAgentName() 551: const workerColor = getTeammateColor() 552: if (!workerId || !workerName) { 553: logForDebugging( 554: `[PermissionSync] Cannot send sandbox permission request: worker ID or name not found`, 555: ) 556: return false 557: } 558: try { 559: const message = createSandboxPermissionRequestMessage({ 560: requestId, 561: workerId, 562: workerName, 563: workerColor, 564: host, 565: }) 566: await writeToMailbox( 567: leaderName, 568: { 569: from: workerName, 570: text: jsonStringify(message), 571: timestamp: new Date().toISOString(), 572: color: workerColor, 573: }, 574: team, 575: ) 576: logForDebugging( 577: `[PermissionSync] Sent sandbox permission request ${requestId} for host ${host} to leader ${leaderName} via mailbox`, 578: ) 579: return true 580: } catch (error) { 581: logForDebugging( 582: `[PermissionSync] Failed to send sandbox permission request via mailbox: ${error}`, 583: ) 584: logError(error) 585: return false 586: } 587: } 588: export async function sendSandboxPermissionResponseViaMailbox( 589: workerName: string, 590: requestId: string, 591: host: string, 592: allow: boolean, 593: teamName?: string, 594: ): Promise<boolean> { 595: const team = teamName || getTeamName() 596: if (!team) { 597: logForDebugging( 598: `[PermissionSync] Cannot send sandbox permission response: team name not found`, 599: ) 600: return false 601: } 602: try { 603: const message = createSandboxPermissionResponseMessage({ 604: requestId, 605: host, 606: allow, 607: }) 608: const senderName = getAgentName() || 'team-lead' 609: await writeToMailbox( 610: workerName, 611: { 612: from: senderName, 613: text: jsonStringify(message), 614: timestamp: new Date().toISOString(), 615: }, 616: team, 617: ) 618: logForDebugging( 619: `[PermissionSync] Sent sandbox permission response for ${requestId} (host: ${host}, allow: ${allow}) to worker ${workerName} via mailbox`, 620: ) 621: return true 622: } catch (error) { 623: logForDebugging( 624: `[PermissionSync] Failed to send sandbox permission response via mailbox: ${error}`, 625: ) 626: logError(error) 627: return false 628: } 629: }

File: src/utils/swarm/reconnection.ts

typescript 1: import type { AppState } from '../../state/AppState.js' 2: import { logForDebugging } from '../debug.js' 3: import { logError } from '../log.js' 4: import { getDynamicTeamContext } from '../teammate.js' 5: import { getTeamFilePath, readTeamFile } from './teamHelpers.js' 6: export function computeInitialTeamContext(): 7: | AppState['teamContext'] 8: | undefined { 9: const context = getDynamicTeamContext() 10: if (!context?.teamName || !context?.agentName) { 11: logForDebugging( 12: '[Reconnection] computeInitialTeamContext: No teammate context set (not a teammate)', 13: ) 14: return undefined 15: } 16: const { teamName, agentId, agentName } = context 17: const teamFile = readTeamFile(teamName) 18: if (!teamFile) { 19: logError( 20: new Error( 21: `[computeInitialTeamContext] Could not read team file for ${teamName}`, 22: ), 23: ) 24: return undefined 25: } 26: const teamFilePath = getTeamFilePath(teamName) 27: const isLeader = !agentId 28: logForDebugging( 29: `[Reconnection] Computed initial team context for ${isLeader ? 'leader' : `teammate ${agentName}`} in team ${teamName}`, 30: ) 31: return { 32: teamName, 33: teamFilePath, 34: leadAgentId: teamFile.leadAgentId, 35: selfAgentId: agentId, 36: selfAgentName: agentName, 37: isLeader, 38: teammates: {}, 39: } 40: } 41: export function initializeTeammateContextFromSession( 42: setAppState: (updater: (prev: AppState) => AppState) => void, 43: teamName: string, 44: agentName: string, 45: ): void { 46: const teamFile = readTeamFile(teamName) 47: if (!teamFile) { 48: logError( 49: new Error( 50: `[initializeTeammateContextFromSession] Could not read team file for ${teamName} (agent: ${agentName})`, 51: ), 52: ) 53: return 54: } 55: const member = teamFile.members.find(m => m.name === agentName) 56: if (!member) { 57: logForDebugging( 58: `[Reconnection] Member ${agentName} not found in team ${teamName} - may have been removed`, 59: ) 60: } 61: const agentId = member?.agentId 62: const teamFilePath = getTeamFilePath(teamName) 63: setAppState(prev => ({ 64: ...prev, 65: teamContext: { 66: teamName, 67: teamFilePath, 68: leadAgentId: teamFile.leadAgentId, 69: selfAgentId: agentId, 70: selfAgentName: agentName, 71: isLeader: false, 72: teammates: {}, 73: }, 74: })) 75: logForDebugging( 76: `[Reconnection] Initialized agent context from session for ${agentName} in team ${teamName}`, 77: ) 78: }

File: src/utils/swarm/spawnInProcess.ts

typescript 1: import sample from 'lodash-es/sample.js' 2: import { getSessionId } from '../../bootstrap/state.js' 3: import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js' 4: import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' 5: import type { AppState } from '../../state/AppState.js' 6: import { createTaskStateBase, generateTaskId } from '../../Task.js' 7: import type { 8: InProcessTeammateTaskState, 9: TeammateIdentity, 10: } from '../../tasks/InProcessTeammateTask/types.js' 11: import { createAbortController } from '../abortController.js' 12: import { formatAgentId } from '../agentId.js' 13: import { registerCleanup } from '../cleanupRegistry.js' 14: import { logForDebugging } from '../debug.js' 15: import { emitTaskTerminatedSdk } from '../sdkEventQueue.js' 16: import { evictTaskOutput } from '../task/diskOutput.js' 17: import { 18: evictTerminalTask, 19: registerTask, 20: STOPPED_DISPLAY_MS, 21: } from '../task/framework.js' 22: import { createTeammateContext } from '../teammateContext.js' 23: import { 24: isPerfettoTracingEnabled, 25: registerAgent as registerPerfettoAgent, 26: unregisterAgent as unregisterPerfettoAgent, 27: } from '../telemetry/perfettoTracing.js' 28: import { removeMemberByAgentId } from './teamHelpers.js' 29: type SetAppStateFn = (updater: (prev: AppState) => AppState) => void 30: export type SpawnContext = { 31: setAppState: SetAppStateFn 32: toolUseId?: string 33: } 34: export type InProcessSpawnConfig = { 35: name: string 36: teamName: string 37: prompt: string 38: color?: string 39: planModeRequired: boolean 40: model?: string 41: } 42: export type InProcessSpawnOutput = { 43: success: boolean 44: agentId: string 45: taskId?: string 46: abortController?: AbortController 47: teammateContext?: ReturnType<typeof createTeammateContext> 48: error?: string 49: } 50: export async function spawnInProcessTeammate( 51: config: InProcessSpawnConfig, 52: context: SpawnContext, 53: ): Promise<InProcessSpawnOutput> { 54: const { name, teamName, prompt, color, planModeRequired, model } = config 55: const { setAppState } = context 56: const agentId = formatAgentId(name, teamName) 57: const taskId = generateTaskId('in_process_teammate') 58: logForDebugging( 59: `[spawnInProcessTeammate] Spawning ${agentId} (taskId: ${taskId})`, 60: ) 61: try { 62: const abortController = createAbortController() 63: const parentSessionId = getSessionId() 64: const identity: TeammateIdentity = { 65: agentId, 66: agentName: name, 67: teamName, 68: color, 69: planModeRequired, 70: parentSessionId, 71: } 72: const teammateContext = createTeammateContext({ 73: agentId, 74: agentName: name, 75: teamName, 76: color, 77: planModeRequired, 78: parentSessionId, 79: abortController, 80: }) 81: if (isPerfettoTracingEnabled()) { 82: registerPerfettoAgent(agentId, name, parentSessionId) 83: } 84: const description = `${name}: ${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}` 85: const taskState: InProcessTeammateTaskState = { 86: ...createTaskStateBase( 87: taskId, 88: 'in_process_teammate', 89: description, 90: context.toolUseId, 91: ), 92: type: 'in_process_teammate', 93: status: 'running', 94: identity, 95: prompt, 96: model, 97: abortController, 98: awaitingPlanApproval: false, 99: spinnerVerb: sample(getSpinnerVerbs()), 100: pastTenseVerb: sample(TURN_COMPLETION_VERBS), 101: permissionMode: planModeRequired ? 'plan' : 'default', 102: isIdle: false, 103: shutdownRequested: false, 104: lastReportedToolCount: 0, 105: lastReportedTokenCount: 0, 106: pendingUserMessages: [], 107: messages: [], 108: } 109: const unregisterCleanup = registerCleanup(async () => { 110: logForDebugging(`[spawnInProcessTeammate] Cleanup called for ${agentId}`) 111: abortController.abort() 112: }) 113: taskState.unregisterCleanup = unregisterCleanup 114: registerTask(taskState, setAppState) 115: logForDebugging( 116: `[spawnInProcessTeammate] Registered ${agentId} in AppState`, 117: ) 118: return { 119: success: true, 120: agentId, 121: taskId, 122: abortController, 123: teammateContext, 124: } 125: } catch (error) { 126: const errorMessage = 127: error instanceof Error ? error.message : 'Unknown error during spawn' 128: logForDebugging( 129: `[spawnInProcessTeammate] Failed to spawn ${agentId}: ${errorMessage}`, 130: ) 131: return { 132: success: false, 133: agentId, 134: error: errorMessage, 135: } 136: } 137: } 138: export function killInProcessTeammate( 139: taskId: string, 140: setAppState: SetAppStateFn, 141: ): boolean { 142: let killed = false 143: let teamName: string | null = null 144: let agentId: string | null = null 145: let toolUseId: string | undefined 146: let description: string | undefined 147: setAppState((prev: AppState) => { 148: const task = prev.tasks[taskId] 149: if (!task || task.type !== 'in_process_teammate') { 150: return prev 151: } 152: const teammateTask = task as InProcessTeammateTaskState 153: if (teammateTask.status !== 'running') { 154: return prev 155: } 156: teamName = teammateTask.identity.teamName 157: agentId = teammateTask.identity.agentId 158: toolUseId = teammateTask.toolUseId 159: description = teammateTask.description 160: teammateTask.abortController?.abort() 161: teammateTask.unregisterCleanup?.() 162: killed = true 163: teammateTask.onIdleCallbacks?.forEach(cb => cb()) 164: let updatedTeamContext = prev.teamContext 165: if (prev.teamContext && prev.teamContext.teammates && agentId) { 166: const { [agentId]: _, ...remainingTeammates } = prev.teamContext.teammates 167: updatedTeamContext = { 168: ...prev.teamContext, 169: teammates: remainingTeammates, 170: } 171: } 172: return { 173: ...prev, 174: teamContext: updatedTeamContext, 175: tasks: { 176: ...prev.tasks, 177: [taskId]: { 178: ...teammateTask, 179: status: 'killed' as const, 180: notified: true, 181: endTime: Date.now(), 182: onIdleCallbacks: [], 183: messages: teammateTask.messages?.length 184: ? [teammateTask.messages[teammateTask.messages.length - 1]!] 185: : undefined, 186: pendingUserMessages: [], 187: inProgressToolUseIDs: undefined, 188: abortController: undefined, 189: unregisterCleanup: undefined, 190: currentWorkAbortController: undefined, 191: }, 192: }, 193: } 194: }) 195: if (teamName && agentId) { 196: removeMemberByAgentId(teamName, agentId) 197: } 198: if (killed) { 199: void evictTaskOutput(taskId) 200: emitTaskTerminatedSdk(taskId, 'stopped', { 201: toolUseId, 202: summary: description, 203: }) 204: setTimeout( 205: evictTerminalTask.bind(null, taskId, setAppState), 206: STOPPED_DISPLAY_MS, 207: ) 208: } 209: if (agentId) { 210: unregisterPerfettoAgent(agentId) 211: } 212: return killed 213: }

File: src/utils/swarm/spawnUtils.ts

typescript 1: import { 2: getChromeFlagOverride, 3: getFlagSettingsPath, 4: getInlinePlugins, 5: getMainLoopModelOverride, 6: getSessionBypassPermissionsMode, 7: } from '../../bootstrap/state.js' 8: import { quote } from '../bash/shellQuote.js' 9: import { isInBundledMode } from '../bundledMode.js' 10: import type { PermissionMode } from '../permissions/PermissionMode.js' 11: import { getTeammateModeFromSnapshot } from './backends/teammateModeSnapshot.js' 12: import { TEAMMATE_COMMAND_ENV_VAR } from './constants.js' 13: export function getTeammateCommand(): string { 14: if (process.env[TEAMMATE_COMMAND_ENV_VAR]) { 15: return process.env[TEAMMATE_COMMAND_ENV_VAR] 16: } 17: return isInBundledMode() ? process.execPath : process.argv[1]! 18: } 19: export function buildInheritedCliFlags(options?: { 20: planModeRequired?: boolean 21: permissionMode?: PermissionMode 22: }): string { 23: const flags: string[] = [] 24: const { planModeRequired, permissionMode } = options || {} 25: if (planModeRequired) { 26: } else if ( 27: permissionMode === 'bypassPermissions' || 28: getSessionBypassPermissionsMode() 29: ) { 30: flags.push('--dangerously-skip-permissions') 31: } else if (permissionMode === 'acceptEdits') { 32: flags.push('--permission-mode acceptEdits') 33: } 34: const modelOverride = getMainLoopModelOverride() 35: if (modelOverride) { 36: flags.push(`--model ${quote([modelOverride])}`) 37: } 38: const settingsPath = getFlagSettingsPath() 39: if (settingsPath) { 40: flags.push(`--settings ${quote([settingsPath])}`) 41: } 42: const inlinePlugins = getInlinePlugins() 43: for (const pluginDir of inlinePlugins) { 44: flags.push(`--plugin-dir ${quote([pluginDir])}`) 45: } 46: const sessionMode = getTeammateModeFromSnapshot() 47: flags.push(`--teammate-mode ${sessionMode}`) 48: const chromeFlagOverride = getChromeFlagOverride() 49: if (chromeFlagOverride === true) { 50: flags.push('--chrome') 51: } else if (chromeFlagOverride === false) { 52: flags.push('--no-chrome') 53: } 54: return flags.join(' ') 55: } 56: const TEAMMATE_ENV_VARS = [ 57: 'CLAUDE_CODE_USE_BEDROCK', 58: 'CLAUDE_CODE_USE_VERTEX', 59: 'CLAUDE_CODE_USE_FOUNDRY', 60: 'ANTHROPIC_BASE_URL', 61: 'CLAUDE_CONFIG_DIR', 62: 'CLAUDE_CODE_REMOTE', 63: 'CLAUDE_CODE_REMOTE_MEMORY_DIR', 64: 'HTTPS_PROXY', 65: 'https_proxy', 66: 'HTTP_PROXY', 67: 'http_proxy', 68: 'NO_PROXY', 69: 'no_proxy', 70: 'SSL_CERT_FILE', 71: 'NODE_EXTRA_CA_CERTS', 72: 'REQUESTS_CA_BUNDLE', 73: 'CURL_CA_BUNDLE', 74: ] as const 75: export function buildInheritedEnvVars(): string { 76: const envVars = ['CLAUDECODE=1', 'CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1'] 77: for (const key of TEAMMATE_ENV_VARS) { 78: const value = process.env[key] 79: if (value !== undefined && value !== '') { 80: envVars.push(`${key}=${quote([value])}`) 81: } 82: } 83: return envVars.join(' ') 84: }

File: src/utils/swarm/teamHelpers.ts

typescript 1: import { mkdirSync, readFileSync, writeFileSync } from 'fs' 2: import { mkdir, readFile, rm, writeFile } from 'fs/promises' 3: import { join } from 'path' 4: import { z } from 'zod/v4' 5: import { getSessionCreatedTeams } from '../../bootstrap/state.js' 6: import { logForDebugging } from '../debug.js' 7: import { getTeamsDir } from '../envUtils.js' 8: import { errorMessage, getErrnoCode } from '../errors.js' 9: import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' 10: import { gitExe } from '../git.js' 11: import { lazySchema } from '../lazySchema.js' 12: import type { PermissionMode } from '../permissions/PermissionMode.js' 13: import { jsonParse, jsonStringify } from '../slowOperations.js' 14: import { getTasksDir, notifyTasksUpdated } from '../tasks.js' 15: import { getAgentName, getTeamName, isTeammate } from '../teammate.js' 16: import { type BackendType, isPaneBackend } from './backends/types.js' 17: import { TEAM_LEAD_NAME } from './constants.js' 18: export const inputSchema = lazySchema(() => 19: z.strictObject({ 20: operation: z 21: .enum(['spawnTeam', 'cleanup']) 22: .describe( 23: 'Operation: spawnTeam to create a team, cleanup to remove team and task directories.', 24: ), 25: agent_type: z 26: .string() 27: .optional() 28: .describe( 29: 'Type/role of the team lead (e.g., "researcher", "test-runner"). ' + 30: 'Used for team file and inter-agent coordination.', 31: ), 32: team_name: z 33: .string() 34: .optional() 35: .describe('Name for the new team to create (required for spawnTeam).'), 36: description: z 37: .string() 38: .optional() 39: .describe('Team description/purpose (only used with spawnTeam).'), 40: }), 41: ) 42: export type SpawnTeamOutput = { 43: team_name: string 44: team_file_path: string 45: lead_agent_id: string 46: } 47: export type CleanupOutput = { 48: success: boolean 49: message: string 50: team_name?: string 51: } 52: export type TeamAllowedPath = { 53: path: string 54: toolName: string 55: addedBy: string 56: addedAt: number 57: } 58: export type TeamFile = { 59: name: string 60: description?: string 61: createdAt: number 62: leadAgentId: string 63: leadSessionId?: string 64: hiddenPaneIds?: string[] 65: teamAllowedPaths?: TeamAllowedPath[] 66: members: Array<{ 67: agentId: string 68: name: string 69: agentType?: string 70: model?: string 71: prompt?: string 72: color?: string 73: planModeRequired?: boolean 74: joinedAt: number 75: tmuxPaneId: string 76: cwd: string 77: worktreePath?: string 78: sessionId?: string 79: subscriptions: string[] 80: backendType?: BackendType 81: isActive?: boolean 82: mode?: PermissionMode 83: }> 84: } 85: export type Input = z.infer<ReturnType<typeof inputSchema>> 86: export type Output = SpawnTeamOutput 87: export function sanitizeName(name: string): string { 88: return name.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase() 89: } 90: export function sanitizeAgentName(name: string): string { 91: return name.replace(/@/g, '-') 92: } 93: export function getTeamDir(teamName: string): string { 94: return join(getTeamsDir(), sanitizeName(teamName)) 95: } 96: export function getTeamFilePath(teamName: string): string { 97: return join(getTeamDir(teamName), 'config.json') 98: } 99: export function readTeamFile(teamName: string): TeamFile | null { 100: try { 101: const content = readFileSync(getTeamFilePath(teamName), 'utf-8') 102: return jsonParse(content) as TeamFile 103: } catch (e) { 104: if (getErrnoCode(e) === 'ENOENT') return null 105: logForDebugging( 106: `[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`, 107: ) 108: return null 109: } 110: } 111: export async function readTeamFileAsync( 112: teamName: string, 113: ): Promise<TeamFile | null> { 114: try { 115: const content = await readFile(getTeamFilePath(teamName), 'utf-8') 116: return jsonParse(content) as TeamFile 117: } catch (e) { 118: if (getErrnoCode(e) === 'ENOENT') return null 119: logForDebugging( 120: `[TeammateTool] Failed to read team file for ${teamName}: ${errorMessage(e)}`, 121: ) 122: return null 123: } 124: } 125: function writeTeamFile(teamName: string, teamFile: TeamFile): void { 126: const teamDir = getTeamDir(teamName) 127: mkdirSync(teamDir, { recursive: true }) 128: writeFileSync(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2)) 129: } 130: export async function writeTeamFileAsync( 131: teamName: string, 132: teamFile: TeamFile, 133: ): Promise<void> { 134: const teamDir = getTeamDir(teamName) 135: await mkdir(teamDir, { recursive: true }) 136: await writeFile(getTeamFilePath(teamName), jsonStringify(teamFile, null, 2)) 137: } 138: export function removeTeammateFromTeamFile( 139: teamName: string, 140: identifier: { agentId?: string; name?: string }, 141: ): boolean { 142: const identifierStr = identifier.agentId || identifier.name 143: if (!identifierStr) { 144: logForDebugging( 145: '[TeammateTool] removeTeammateFromTeamFile called with no identifier', 146: ) 147: return false 148: } 149: const teamFile = readTeamFile(teamName) 150: if (!teamFile) { 151: logForDebugging( 152: `[TeammateTool] Cannot remove teammate ${identifierStr}: failed to read team file for "${teamName}"`, 153: ) 154: return false 155: } 156: const originalLength = teamFile.members.length 157: teamFile.members = teamFile.members.filter(m => { 158: if (identifier.agentId && m.agentId === identifier.agentId) return false 159: if (identifier.name && m.name === identifier.name) return false 160: return true 161: }) 162: if (teamFile.members.length === originalLength) { 163: logForDebugging( 164: `[TeammateTool] Teammate ${identifierStr} not found in team file for "${teamName}"`, 165: ) 166: return false 167: } 168: writeTeamFile(teamName, teamFile) 169: logForDebugging( 170: `[TeammateTool] Removed teammate from team file: ${identifierStr}`, 171: ) 172: return true 173: } 174: export function addHiddenPaneId(teamName: string, paneId: string): boolean { 175: const teamFile = readTeamFile(teamName) 176: if (!teamFile) { 177: return false 178: } 179: const hiddenPaneIds = teamFile.hiddenPaneIds ?? [] 180: if (!hiddenPaneIds.includes(paneId)) { 181: hiddenPaneIds.push(paneId) 182: teamFile.hiddenPaneIds = hiddenPaneIds 183: writeTeamFile(teamName, teamFile) 184: logForDebugging( 185: `[TeammateTool] Added ${paneId} to hidden panes for team ${teamName}`, 186: ) 187: } 188: return true 189: } 190: export function removeHiddenPaneId(teamName: string, paneId: string): boolean { 191: const teamFile = readTeamFile(teamName) 192: if (!teamFile) { 193: return false 194: } 195: const hiddenPaneIds = teamFile.hiddenPaneIds ?? [] 196: const index = hiddenPaneIds.indexOf(paneId) 197: if (index !== -1) { 198: hiddenPaneIds.splice(index, 1) 199: teamFile.hiddenPaneIds = hiddenPaneIds 200: writeTeamFile(teamName, teamFile) 201: logForDebugging( 202: `[TeammateTool] Removed ${paneId} from hidden panes for team ${teamName}`, 203: ) 204: } 205: return true 206: } 207: export function removeMemberFromTeam( 208: teamName: string, 209: tmuxPaneId: string, 210: ): boolean { 211: const teamFile = readTeamFile(teamName) 212: if (!teamFile) { 213: return false 214: } 215: const memberIndex = teamFile.members.findIndex( 216: m => m.tmuxPaneId === tmuxPaneId, 217: ) 218: if (memberIndex === -1) { 219: return false 220: } 221: teamFile.members.splice(memberIndex, 1) 222: if (teamFile.hiddenPaneIds) { 223: const hiddenIndex = teamFile.hiddenPaneIds.indexOf(tmuxPaneId) 224: if (hiddenIndex !== -1) { 225: teamFile.hiddenPaneIds.splice(hiddenIndex, 1) 226: } 227: } 228: writeTeamFile(teamName, teamFile) 229: logForDebugging( 230: `[TeammateTool] Removed member with pane ${tmuxPaneId} from team ${teamName}`, 231: ) 232: return true 233: } 234: export function removeMemberByAgentId( 235: teamName: string, 236: agentId: string, 237: ): boolean { 238: const teamFile = readTeamFile(teamName) 239: if (!teamFile) { 240: return false 241: } 242: const memberIndex = teamFile.members.findIndex(m => m.agentId === agentId) 243: if (memberIndex === -1) { 244: return false 245: } 246: teamFile.members.splice(memberIndex, 1) 247: writeTeamFile(teamName, teamFile) 248: logForDebugging( 249: `[TeammateTool] Removed member ${agentId} from team ${teamName}`, 250: ) 251: return true 252: } 253: export function setMemberMode( 254: teamName: string, 255: memberName: string, 256: mode: PermissionMode, 257: ): boolean { 258: const teamFile = readTeamFile(teamName) 259: if (!teamFile) { 260: return false 261: } 262: const member = teamFile.members.find(m => m.name === memberName) 263: if (!member) { 264: logForDebugging( 265: `[TeammateTool] Cannot set member mode: member ${memberName} not found in team ${teamName}`, 266: ) 267: return false 268: } 269: if (member.mode === mode) { 270: return true 271: } 272: const updatedMembers = teamFile.members.map(m => 273: m.name === memberName ? { ...m, mode } : m, 274: ) 275: writeTeamFile(teamName, { ...teamFile, members: updatedMembers }) 276: logForDebugging( 277: `[TeammateTool] Set member ${memberName} in team ${teamName} to mode: ${mode}`, 278: ) 279: return true 280: } 281: export function syncTeammateMode( 282: mode: PermissionMode, 283: teamNameOverride?: string, 284: ): void { 285: if (!isTeammate()) return 286: const teamName = teamNameOverride ?? getTeamName() 287: const agentName = getAgentName() 288: if (teamName && agentName) { 289: setMemberMode(teamName, agentName, mode) 290: } 291: } 292: export function setMultipleMemberModes( 293: teamName: string, 294: modeUpdates: Array<{ memberName: string; mode: PermissionMode }>, 295: ): boolean { 296: const teamFile = readTeamFile(teamName) 297: if (!teamFile) { 298: return false 299: } 300: const updateMap = new Map(modeUpdates.map(u => [u.memberName, u.mode])) 301: let anyChanged = false 302: const updatedMembers = teamFile.members.map(member => { 303: const newMode = updateMap.get(member.name) 304: if (newMode !== undefined && member.mode !== newMode) { 305: anyChanged = true 306: return { ...member, mode: newMode } 307: } 308: return member 309: }) 310: if (anyChanged) { 311: writeTeamFile(teamName, { ...teamFile, members: updatedMembers }) 312: logForDebugging( 313: `[TeammateTool] Set ${modeUpdates.length} member modes in team ${teamName}`, 314: ) 315: } 316: return true 317: } 318: export async function setMemberActive( 319: teamName: string, 320: memberName: string, 321: isActive: boolean, 322: ): Promise<void> { 323: const teamFile = await readTeamFileAsync(teamName) 324: if (!teamFile) { 325: logForDebugging( 326: `[TeammateTool] Cannot set member active: team ${teamName} not found`, 327: ) 328: return 329: } 330: const member = teamFile.members.find(m => m.name === memberName) 331: if (!member) { 332: logForDebugging( 333: `[TeammateTool] Cannot set member active: member ${memberName} not found in team ${teamName}`, 334: ) 335: return 336: } 337: if (member.isActive === isActive) { 338: return 339: } 340: member.isActive = isActive 341: await writeTeamFileAsync(teamName, teamFile) 342: logForDebugging( 343: `[TeammateTool] Set member ${memberName} in team ${teamName} to ${isActive ? 'active' : 'idle'}`, 344: ) 345: } 346: async function destroyWorktree(worktreePath: string): Promise<void> { 347: const gitFilePath = join(worktreePath, '.git') 348: let mainRepoPath: string | null = null 349: try { 350: const gitFileContent = (await readFile(gitFilePath, 'utf-8')).trim() 351: const match = gitFileContent.match(/^gitdir:\s*(.+)$/) 352: if (match && match[1]) { 353: const worktreeGitDir = match[1] 354: const mainGitDir = join(worktreeGitDir, '..', '..') 355: mainRepoPath = join(mainGitDir, '..') 356: } 357: } catch { 358: } 359: if (mainRepoPath) { 360: const result = await execFileNoThrowWithCwd( 361: gitExe(), 362: ['worktree', 'remove', '--force', worktreePath], 363: { cwd: mainRepoPath }, 364: ) 365: if (result.code === 0) { 366: logForDebugging( 367: `[TeammateTool] Removed worktree via git: ${worktreePath}`, 368: ) 369: return 370: } 371: if (result.stderr?.includes('not a working tree')) { 372: logForDebugging( 373: `[TeammateTool] Worktree already removed: ${worktreePath}`, 374: ) 375: return 376: } 377: logForDebugging( 378: `[TeammateTool] git worktree remove failed, falling back to rm: ${result.stderr}`, 379: ) 380: } 381: try { 382: await rm(worktreePath, { recursive: true, force: true }) 383: logForDebugging( 384: `[TeammateTool] Removed worktree directory manually: ${worktreePath}`, 385: ) 386: } catch (error) { 387: logForDebugging( 388: `[TeammateTool] Failed to remove worktree ${worktreePath}: ${errorMessage(error)}`, 389: ) 390: } 391: } 392: export function registerTeamForSessionCleanup(teamName: string): void { 393: getSessionCreatedTeams().add(teamName) 394: } 395: export function unregisterTeamForSessionCleanup(teamName: string): void { 396: getSessionCreatedTeams().delete(teamName) 397: } 398: export async function cleanupSessionTeams(): Promise<void> { 399: const sessionCreatedTeams = getSessionCreatedTeams() 400: if (sessionCreatedTeams.size === 0) return 401: const teams = Array.from(sessionCreatedTeams) 402: logForDebugging( 403: `cleanupSessionTeams: removing ${teams.length} orphan team dir(s): ${teams.join(', ')}`, 404: ) 405: await Promise.allSettled(teams.map(name => killOrphanedTeammatePanes(name))) 406: await Promise.allSettled(teams.map(name => cleanupTeamDirectories(name))) 407: sessionCreatedTeams.clear() 408: } 409: async function killOrphanedTeammatePanes(teamName: string): Promise<void> { 410: const teamFile = readTeamFile(teamName) 411: if (!teamFile) return 412: const paneMembers = teamFile.members.filter( 413: m => 414: m.name !== TEAM_LEAD_NAME && 415: m.tmuxPaneId && 416: m.backendType && 417: isPaneBackend(m.backendType), 418: ) 419: if (paneMembers.length === 0) return 420: const [{ ensureBackendsRegistered, getBackendByType }, { isInsideTmux }] = 421: await Promise.all([ 422: import('./backends/registry.js'), 423: import('./backends/detection.js'), 424: ]) 425: await ensureBackendsRegistered() 426: const useExternalSession = !(await isInsideTmux()) 427: await Promise.allSettled( 428: paneMembers.map(async m => { 429: if (!m.tmuxPaneId || !m.backendType || !isPaneBackend(m.backendType)) { 430: return 431: } 432: const ok = await getBackendByType(m.backendType).killPane( 433: m.tmuxPaneId, 434: useExternalSession, 435: ) 436: logForDebugging( 437: `cleanupSessionTeams: killPane ${m.name} (${m.backendType} ${m.tmuxPaneId}) → ${ok}`, 438: ) 439: }), 440: ) 441: } 442: export async function cleanupTeamDirectories(teamName: string): Promise<void> { 443: const sanitizedName = sanitizeName(teamName) 444: const teamFile = readTeamFile(teamName) 445: const worktreePaths: string[] = [] 446: if (teamFile) { 447: for (const member of teamFile.members) { 448: if (member.worktreePath) { 449: worktreePaths.push(member.worktreePath) 450: } 451: } 452: } 453: for (const worktreePath of worktreePaths) { 454: await destroyWorktree(worktreePath) 455: } 456: const teamDir = getTeamDir(teamName) 457: try { 458: await rm(teamDir, { recursive: true, force: true }) 459: logForDebugging(`[TeammateTool] Cleaned up team directory: ${teamDir}`) 460: } catch (error) { 461: logForDebugging( 462: `[TeammateTool] Failed to clean up team directory ${teamDir}: ${errorMessage(error)}`, 463: ) 464: } 465: const tasksDir = getTasksDir(sanitizedName) 466: try { 467: await rm(tasksDir, { recursive: true, force: true }) 468: logForDebugging(`[TeammateTool] Cleaned up tasks directory: ${tasksDir}`) 469: notifyTasksUpdated() 470: } catch (error) { 471: logForDebugging( 472: `[TeammateTool] Failed to clean up tasks directory ${tasksDir}: ${errorMessage(error)}`, 473: ) 474: } 475: }

File: src/utils/swarm/teammateInit.ts

typescript 1: import type { AppState } from '../../state/AppState.js' 2: import { logForDebugging } from '../debug.js' 3: import { addFunctionHook } from '../hooks/sessionHooks.js' 4: import { applyPermissionUpdate } from '../permissions/PermissionUpdate.js' 5: import { jsonStringify } from '../slowOperations.js' 6: import { getTeammateColor } from '../teammate.js' 7: import { 8: createIdleNotification, 9: getLastPeerDmSummary, 10: writeToMailbox, 11: } from '../teammateMailbox.js' 12: import { readTeamFile, setMemberActive } from './teamHelpers.js' 13: export function initializeTeammateHooks( 14: setAppState: (updater: (prev: AppState) => AppState) => void, 15: sessionId: string, 16: teamInfo: { teamName: string; agentId: string; agentName: string }, 17: ): void { 18: const { teamName, agentId, agentName } = teamInfo 19: const teamFile = readTeamFile(teamName) 20: if (!teamFile) { 21: logForDebugging(`[TeammateInit] Team file not found for team: ${teamName}`) 22: return 23: } 24: const leadAgentId = teamFile.leadAgentId 25: if (teamFile.teamAllowedPaths && teamFile.teamAllowedPaths.length > 0) { 26: logForDebugging( 27: `[TeammateInit] Found ${teamFile.teamAllowedPaths.length} team-wide allowed path(s)`, 28: ) 29: for (const allowedPath of teamFile.teamAllowedPaths) { 30: const ruleContent = allowedPath.path.startsWith('/') 31: ? `/${allowedPath.path}/**` 32: : `${allowedPath.path}/**` 33: logForDebugging( 34: `[TeammateInit] Applying team permission: ${allowedPath.toolName} allowed in ${allowedPath.path} (rule: ${ruleContent})`, 35: ) 36: setAppState(prev => ({ 37: ...prev, 38: toolPermissionContext: applyPermissionUpdate( 39: prev.toolPermissionContext, 40: { 41: type: 'addRules', 42: rules: [ 43: { 44: toolName: allowedPath.toolName, 45: ruleContent, 46: }, 47: ], 48: behavior: 'allow', 49: destination: 'session', 50: }, 51: ), 52: })) 53: } 54: } 55: const leadMember = teamFile.members.find(m => m.agentId === leadAgentId) 56: const leadAgentName = leadMember?.name || 'team-lead' 57: if (agentId === leadAgentId) { 58: logForDebugging( 59: '[TeammateInit] This agent is the team leader - skipping idle notification hook', 60: ) 61: return 62: } 63: logForDebugging( 64: `[TeammateInit] Registering Stop hook for teammate ${agentName} to notify leader ${leadAgentName}`, 65: ) 66: addFunctionHook( 67: setAppState, 68: sessionId, 69: 'Stop', 70: '', // No matcher - applies to all Stop events 71: async (messages, _signal) => { 72: // Mark this teammate as idle in the team config (fire and forget) 73: void setMemberActive(teamName, agentName, false) 74: // Send idle notification to the team leader using agent name (not UUID) 75: // Must await to ensure the write completes before process shutdown 76: const notification = createIdleNotification(agentName, { 77: idleReason: 'available', 78: summary: getLastPeerDmSummary(messages), 79: }) 80: await writeToMailbox(leadAgentName, { 81: from: agentName, 82: text: jsonStringify(notification), 83: timestamp: new Date().toISOString(), 84: color: getTeammateColor(), 85: }) 86: logForDebugging( 87: `[TeammateInit] Sent idle notification to leader ${leadAgentName}`, 88: ) 89: return true 90: }, 91: 'Failed to send idle notification to team leader', 92: { 93: timeout: 10000, 94: }, 95: ) 96: }

File: src/utils/swarm/teammateLayoutManager.ts

typescript 1: import type { AgentColorName } from '../../tools/AgentTool/agentColorManager.js' 2: import { AGENT_COLORS } from '../../tools/AgentTool/agentColorManager.js' 3: import { detectAndGetBackend } from './backends/registry.js' 4: import type { PaneBackend } from './backends/types.js' 5: const teammateColorAssignments = new Map<string, AgentColorName>() 6: let colorIndex = 0 7: async function getBackend(): Promise<PaneBackend> { 8: return (await detectAndGetBackend()).backend 9: } 10: export function assignTeammateColor(teammateId: string): AgentColorName { 11: const existing = teammateColorAssignments.get(teammateId) 12: if (existing) { 13: return existing 14: } 15: const color = AGENT_COLORS[colorIndex % AGENT_COLORS.length]! 16: teammateColorAssignments.set(teammateId, color) 17: colorIndex++ 18: return color 19: } 20: export function getTeammateColor( 21: teammateId: string, 22: ): AgentColorName | undefined { 23: return teammateColorAssignments.get(teammateId) 24: } 25: export function clearTeammateColors(): void { 26: teammateColorAssignments.clear() 27: colorIndex = 0 28: } 29: export async function isInsideTmux(): Promise<boolean> { 30: const { isInsideTmux: checkTmux } = await import('./backends/detection.js') 31: return checkTmux() 32: } 33: export async function createTeammatePaneInSwarmView( 34: teammateName: string, 35: teammateColor: AgentColorName, 36: ): Promise<{ paneId: string; isFirstTeammate: boolean }> { 37: const backend = await getBackend() 38: return backend.createTeammatePaneInSwarmView(teammateName, teammateColor) 39: } 40: export async function enablePaneBorderStatus( 41: windowTarget?: string, 42: useSwarmSocket = false, 43: ): Promise<void> { 44: const backend = await getBackend() 45: return backend.enablePaneBorderStatus(windowTarget, useSwarmSocket) 46: } 47: export async function sendCommandToPane( 48: paneId: string, 49: command: string, 50: useSwarmSocket = false, 51: ): Promise<void> { 52: const backend = await getBackend() 53: return backend.sendCommandToPane(paneId, command, useSwarmSocket) 54: }

File: src/utils/swarm/teammateModel.ts

typescript 1: import { CLAUDE_OPUS_4_6_CONFIG } from '../model/configs.js' 2: import { getAPIProvider } from '../model/providers.js' 3: export function getHardcodedTeammateModelFallback(): string { 4: return CLAUDE_OPUS_4_6_CONFIG[getAPIProvider()] 5: }

File: src/utils/swarm/teammatePromptAddendum.ts

typescript 1: export const TEAMMATE_SYSTEM_PROMPT_ADDENDUM = ` 2: # Agent Teammate Communication 3: IMPORTANT: You are running as an agent in a team. To communicate with anyone on your team: 4: - Use the SendMessage tool with \`to: "<name>"\` to send messages to specific teammates 5: - Use the SendMessage tool with \`to: "*"\` sparingly for team-wide broadcasts 6: Just writing a response in text is not visible to others on your team - you MUST use the SendMessage tool. 7: The user interacts primarily with the team lead. Your work is coordinated through the task system and teammate messaging. 8: `

File: src/utils/task/diskOutput.ts

typescript 1: import { constants as fsConstants } from 'fs' 2: import { 3: type FileHandle, 4: mkdir, 5: open, 6: stat, 7: symlink, 8: unlink, 9: } from 'fs/promises' 10: import { join } from 'path' 11: import { getSessionId } from '../../bootstrap/state.js' 12: import { getErrnoCode } from '../errors.js' 13: import { readFileRange, tailFile } from '../fsOperations.js' 14: import { logError } from '../log.js' 15: import { getProjectTempDir } from '../permissions/filesystem.js' 16: const O_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0 17: const DEFAULT_MAX_READ_BYTES = 8 * 1024 * 1024 18: export const MAX_TASK_OUTPUT_BYTES = 5 * 1024 * 1024 * 1024 19: export const MAX_TASK_OUTPUT_BYTES_DISPLAY = '5GB' 20: let _taskOutputDir: string | undefined 21: export function getTaskOutputDir(): string { 22: if (_taskOutputDir === undefined) { 23: _taskOutputDir = join(getProjectTempDir(), getSessionId(), 'tasks') 24: } 25: return _taskOutputDir 26: } 27: export function _resetTaskOutputDirForTest(): void { 28: _taskOutputDir = undefined 29: } 30: async function ensureOutputDir(): Promise<void> { 31: await mkdir(getTaskOutputDir(), { recursive: true }) 32: } 33: export function getTaskOutputPath(taskId: string): string { 34: return join(getTaskOutputDir(), `${taskId}.output`) 35: } 36: const _pendingOps = new Set<Promise<unknown>>() 37: function track<T>(p: Promise<T>): Promise<T> { 38: _pendingOps.add(p) 39: void p.finally(() => _pendingOps.delete(p)).catch(() => {}) 40: return p 41: } 42: export class DiskTaskOutput { 43: #path: string 44: #fileHandle: FileHandle | null = null 45: #queue: string[] = [] 46: #bytesWritten = 0 47: #capped = false 48: #flushPromise: Promise<void> | null = null 49: #flushResolve: (() => void) | null = null 50: constructor(taskId: string) { 51: this.#path = getTaskOutputPath(taskId) 52: } 53: append(content: string): void { 54: if (this.#capped) { 55: return 56: } 57: this.#bytesWritten += content.length 58: if (this.#bytesWritten > MAX_TASK_OUTPUT_BYTES) { 59: this.#capped = true 60: this.#queue.push( 61: `\n[output truncated: exceeded ${MAX_TASK_OUTPUT_BYTES_DISPLAY} disk cap]\n`, 62: ) 63: } else { 64: this.#queue.push(content) 65: } 66: if (!this.#flushPromise) { 67: this.#flushPromise = new Promise<void>(resolve => { 68: this.#flushResolve = resolve 69: }) 70: void track(this.#drain()) 71: } 72: } 73: flush(): Promise<void> { 74: return this.#flushPromise ?? Promise.resolve() 75: } 76: cancel(): void { 77: this.#queue.length = 0 78: } 79: async #drainAllChunks(): Promise<void> { 80: while (true) { 81: try { 82: if (!this.#fileHandle) { 83: await ensureOutputDir() 84: this.#fileHandle = await open( 85: this.#path, 86: process.platform === 'win32' 87: ? 'a' 88: : fsConstants.O_WRONLY | 89: fsConstants.O_APPEND | 90: fsConstants.O_CREAT | 91: O_NOFOLLOW, 92: ) 93: } 94: while (true) { 95: await this.#writeAllChunks() 96: if (this.#queue.length === 0) { 97: break 98: } 99: } 100: } finally { 101: if (this.#fileHandle) { 102: const fileHandle = this.#fileHandle 103: this.#fileHandle = null 104: await fileHandle.close() 105: } 106: } 107: if (this.#queue.length) { 108: continue 109: } 110: break 111: } 112: } 113: #writeAllChunks(): Promise<void> { 114: return this.#fileHandle!.appendFile( 115: this.#queueToBuffers(), 116: ) 117: } 118: #queueToBuffers(): Buffer { 119: const queue = this.#queue.splice(0, this.#queue.length) 120: let totalLength = 0 121: for (const str of queue) { 122: totalLength += Buffer.byteLength(str, 'utf8') 123: } 124: const buffer = Buffer.allocUnsafe(totalLength) 125: let offset = 0 126: for (const str of queue) { 127: offset += buffer.write(str, offset, 'utf8') 128: } 129: return buffer 130: } 131: async #drain(): Promise<void> { 132: try { 133: await this.#drainAllChunks() 134: } catch (e) { 135: logError(e) 136: if (this.#queue.length > 0) { 137: try { 138: await this.#drainAllChunks() 139: } catch (e2) { 140: logError(e2) 141: } 142: } 143: } finally { 144: const resolve = this.#flushResolve! 145: this.#flushPromise = null 146: this.#flushResolve = null 147: resolve() 148: } 149: } 150: } 151: const outputs = new Map<string, DiskTaskOutput>() 152: export async function _clearOutputsForTest(): Promise<void> { 153: for (const output of outputs.values()) { 154: output.cancel() 155: } 156: while (_pendingOps.size > 0) { 157: await Promise.allSettled([..._pendingOps]) 158: } 159: outputs.clear() 160: } 161: function getOrCreateOutput(taskId: string): DiskTaskOutput { 162: let output = outputs.get(taskId) 163: if (!output) { 164: output = new DiskTaskOutput(taskId) 165: outputs.set(taskId, output) 166: } 167: return output 168: } 169: export function appendTaskOutput(taskId: string, content: string): void { 170: getOrCreateOutput(taskId).append(content) 171: } 172: export async function flushTaskOutput(taskId: string): Promise<void> { 173: const output = outputs.get(taskId) 174: if (output) { 175: await output.flush() 176: } 177: } 178: export function evictTaskOutput(taskId: string): Promise<void> { 179: return track( 180: (async () => { 181: const output = outputs.get(taskId) 182: if (output) { 183: await output.flush() 184: outputs.delete(taskId) 185: } 186: })(), 187: ) 188: } 189: export async function getTaskOutputDelta( 190: taskId: string, 191: fromOffset: number, 192: maxBytes: number = DEFAULT_MAX_READ_BYTES, 193: ): Promise<{ content: string; newOffset: number }> { 194: try { 195: const result = await readFileRange( 196: getTaskOutputPath(taskId), 197: fromOffset, 198: maxBytes, 199: ) 200: if (!result) { 201: return { content: '', newOffset: fromOffset } 202: } 203: return { 204: content: result.content, 205: newOffset: fromOffset + result.bytesRead, 206: } 207: } catch (e) { 208: const code = getErrnoCode(e) 209: if (code === 'ENOENT') { 210: return { content: '', newOffset: fromOffset } 211: } 212: logError(e) 213: return { content: '', newOffset: fromOffset } 214: } 215: } 216: /** 217: * Get output for a task, reading the tail of the file. 218: * Caps at maxBytes to avoid loading multi-GB files into memory. 219: */ 220: export async function getTaskOutput( 221: taskId: string, 222: maxBytes: number = DEFAULT_MAX_READ_BYTES, 223: ): Promise<string> { 224: try { 225: const { content, bytesTotal, bytesRead } = await tailFile( 226: getTaskOutputPath(taskId), 227: maxBytes, 228: ) 229: if (bytesTotal > bytesRead) { 230: return `[${Math.round((bytesTotal - bytesRead) / 1024)}KB of earlier output omitted]\n${content}` 231: } 232: return content 233: } catch (e) { 234: const code = getErrnoCode(e) 235: if (code === 'ENOENT') { 236: return '' 237: } 238: logError(e) 239: return '' 240: } 241: } 242: /** 243: * Get the current size (offset) of a task's output file. 244: */ 245: export async function getTaskOutputSize(taskId: string): Promise<number> { 246: try { 247: return (await stat(getTaskOutputPath(taskId))).size 248: } catch (e) { 249: const code = getErrnoCode(e) 250: if (code === 'ENOENT') { 251: return 0 252: } 253: logError(e) 254: return 0 255: } 256: } 257: export async function cleanupTaskOutput(taskId: string): Promise<void> { 258: const output = outputs.get(taskId) 259: if (output) { 260: output.cancel() 261: outputs.delete(taskId) 262: } 263: try { 264: await unlink(getTaskOutputPath(taskId)) 265: } catch (e) { 266: const code = getErrnoCode(e) 267: if (code === 'ENOENT') { 268: return 269: } 270: logError(e) 271: } 272: } 273: export function initTaskOutput(taskId: string): Promise<string> { 274: return track( 275: (async () => { 276: await ensureOutputDir() 277: const outputPath = getTaskOutputPath(taskId) 278: const fh = await open( 279: outputPath, 280: process.platform === 'win32' 281: ? 'wx' 282: : fsConstants.O_WRONLY | 283: fsConstants.O_CREAT | 284: fsConstants.O_EXCL | 285: O_NOFOLLOW, 286: ) 287: await fh.close() 288: return outputPath 289: })(), 290: ) 291: } 292: export function initTaskOutputAsSymlink( 293: taskId: string, 294: targetPath: string, 295: ): Promise<string> { 296: return track( 297: (async () => { 298: try { 299: await ensureOutputDir() 300: const outputPath = getTaskOutputPath(taskId) 301: try { 302: await symlink(targetPath, outputPath) 303: } catch { 304: await unlink(outputPath) 305: await symlink(targetPath, outputPath) 306: } 307: return outputPath 308: } catch (error) { 309: logError(error) 310: return initTaskOutput(taskId) 311: } 312: })(), 313: ) 314: }

File: src/utils/task/framework.ts

typescript 1: import { 2: OUTPUT_FILE_TAG, 3: STATUS_TAG, 4: SUMMARY_TAG, 5: TASK_ID_TAG, 6: TASK_NOTIFICATION_TAG, 7: TASK_TYPE_TAG, 8: TOOL_USE_ID_TAG, 9: } from '../../constants/xml.js' 10: import type { AppState } from '../../state/AppState.js' 11: import { 12: isTerminalTaskStatus, 13: type TaskStatus, 14: type TaskType, 15: } from '../../Task.js' 16: import type { TaskState } from '../../tasks/types.js' 17: import { enqueuePendingNotification } from '../messageQueueManager.js' 18: import { enqueueSdkEvent } from '../sdkEventQueue.js' 19: import { getTaskOutputDelta, getTaskOutputPath } from './diskOutput.js' 20: export const POLL_INTERVAL_MS = 1000 21: export const STOPPED_DISPLAY_MS = 3_000 22: export const PANEL_GRACE_MS = 30_000 23: export type TaskAttachment = { 24: type: 'task_status' 25: taskId: string 26: toolUseId?: string 27: taskType: TaskType 28: status: TaskStatus 29: description: string 30: deltaSummary: string | null 31: } 32: type SetAppState = (updater: (prev: AppState) => AppState) => void 33: export function updateTaskState<T extends TaskState>( 34: taskId: string, 35: setAppState: SetAppState, 36: updater: (task: T) => T, 37: ): void { 38: setAppState(prev => { 39: const task = prev.tasks?.[taskId] as T | undefined 40: if (!task) { 41: return prev 42: } 43: const updated = updater(task) 44: if (updated === task) { 45: return prev 46: } 47: return { 48: ...prev, 49: tasks: { 50: ...prev.tasks, 51: [taskId]: updated, 52: }, 53: } 54: }) 55: } 56: export function registerTask(task: TaskState, setAppState: SetAppState): void { 57: let isReplacement = false 58: setAppState(prev => { 59: const existing = prev.tasks[task.id] 60: isReplacement = existing !== undefined 61: const merged = 62: existing && 'retain' in existing 63: ? { 64: ...task, 65: retain: existing.retain, 66: startTime: existing.startTime, 67: messages: existing.messages, 68: diskLoaded: existing.diskLoaded, 69: pendingMessages: existing.pendingMessages, 70: } 71: : task 72: return { ...prev, tasks: { ...prev.tasks, [task.id]: merged } } 73: }) 74: if (isReplacement) return 75: enqueueSdkEvent({ 76: type: 'system', 77: subtype: 'task_started', 78: task_id: task.id, 79: tool_use_id: task.toolUseId, 80: description: task.description, 81: task_type: task.type, 82: workflow_name: 83: 'workflowName' in task 84: ? (task.workflowName as string | undefined) 85: : undefined, 86: prompt: 'prompt' in task ? (task.prompt as string) : undefined, 87: }) 88: } 89: export function evictTerminalTask( 90: taskId: string, 91: setAppState: SetAppState, 92: ): void { 93: setAppState(prev => { 94: const task = prev.tasks?.[taskId] 95: if (!task) return prev 96: if (!isTerminalTaskStatus(task.status)) return prev 97: if (!task.notified) return prev 98: if ('retain' in task && (task.evictAfter ?? Infinity) > Date.now()) { 99: return prev 100: } 101: const { [taskId]: _, ...remainingTasks } = prev.tasks 102: return { ...prev, tasks: remainingTasks } 103: }) 104: } 105: export function getRunningTasks(state: AppState): TaskState[] { 106: const tasks = state.tasks ?? {} 107: return Object.values(tasks).filter(task => task.status === 'running') 108: } 109: export async function generateTaskAttachments(state: AppState): Promise<{ 110: attachments: TaskAttachment[] 111: updatedTaskOffsets: Record<string, number> 112: evictedTaskIds: string[] 113: }> { 114: const attachments: TaskAttachment[] = [] 115: const updatedTaskOffsets: Record<string, number> = {} 116: const evictedTaskIds: string[] = [] 117: const tasks = state.tasks ?? {} 118: for (const taskState of Object.values(tasks)) { 119: if (taskState.notified) { 120: switch (taskState.status) { 121: case 'completed': 122: case 'failed': 123: case 'killed': 124: evictedTaskIds.push(taskState.id) 125: continue 126: case 'pending': 127: continue 128: case 'running': 129: break 130: } 131: } 132: if (taskState.status === 'running') { 133: const delta = await getTaskOutputDelta( 134: taskState.id, 135: taskState.outputOffset, 136: ) 137: if (delta.content) { 138: updatedTaskOffsets[taskState.id] = delta.newOffset 139: } 140: } 141: } 142: return { attachments, updatedTaskOffsets, evictedTaskIds } 143: } 144: export function applyTaskOffsetsAndEvictions( 145: setAppState: SetAppState, 146: updatedTaskOffsets: Record<string, number>, 147: evictedTaskIds: string[], 148: ): void { 149: const offsetIds = Object.keys(updatedTaskOffsets) 150: if (offsetIds.length === 0 && evictedTaskIds.length === 0) { 151: return 152: } 153: setAppState(prev => { 154: let changed = false 155: const newTasks = { ...prev.tasks } 156: for (const id of offsetIds) { 157: const fresh = newTasks[id] 158: if (fresh?.status === 'running') { 159: newTasks[id] = { ...fresh, outputOffset: updatedTaskOffsets[id]! } 160: changed = true 161: } 162: } 163: for (const id of evictedTaskIds) { 164: const fresh = newTasks[id] 165: if (!fresh || !isTerminalTaskStatus(fresh.status) || !fresh.notified) { 166: continue 167: } 168: if ('retain' in fresh && (fresh.evictAfter ?? Infinity) > Date.now()) { 169: continue 170: } 171: delete newTasks[id] 172: changed = true 173: } 174: return changed ? { ...prev, tasks: newTasks } : prev 175: }) 176: } 177: export async function pollTasks( 178: getAppState: () => AppState, 179: setAppState: SetAppState, 180: ): Promise<void> { 181: const state = getAppState() 182: const { attachments, updatedTaskOffsets, evictedTaskIds } = 183: await generateTaskAttachments(state) 184: applyTaskOffsetsAndEvictions(setAppState, updatedTaskOffsets, evictedTaskIds) 185: for (const attachment of attachments) { 186: enqueueTaskNotification(attachment) 187: } 188: } 189: function enqueueTaskNotification(attachment: TaskAttachment): void { 190: const statusText = getStatusText(attachment.status) 191: const outputPath = getTaskOutputPath(attachment.taskId) 192: const toolUseIdLine = attachment.toolUseId 193: ? `\n<${TOOL_USE_ID_TAG}>${attachment.toolUseId}</${TOOL_USE_ID_TAG}>` 194: : '' 195: const message = `<${TASK_NOTIFICATION_TAG}> 196: <${TASK_ID_TAG}>${attachment.taskId}</${TASK_ID_TAG}>${toolUseIdLine} 197: <${TASK_TYPE_TAG}>${attachment.taskType}</${TASK_TYPE_TAG}> 198: <${OUTPUT_FILE_TAG}>${outputPath}</${OUTPUT_FILE_TAG}> 199: <${STATUS_TAG}>${attachment.status}</${STATUS_TAG}> 200: <${SUMMARY_TAG}>Task "${attachment.description}" ${statusText}</${SUMMARY_TAG}> 201: </${TASK_NOTIFICATION_TAG}>` 202: enqueuePendingNotification({ value: message, mode: 'task-notification' }) 203: } 204: function getStatusText(status: TaskStatus): string { 205: switch (status) { 206: case 'completed': 207: return 'completed successfully' 208: case 'failed': 209: return 'failed' 210: case 'killed': 211: return 'was stopped' 212: case 'running': 213: return 'is running' 214: case 'pending': 215: return 'is pending' 216: } 217: }

File: src/utils/task/outputFormatting.ts

typescript 1: import { validateBoundedIntEnvVar } from '../envValidation.js' 2: import { getTaskOutputPath } from './diskOutput.js' 3: export const TASK_MAX_OUTPUT_UPPER_LIMIT = 160_000 4: export const TASK_MAX_OUTPUT_DEFAULT = 32_000 5: export function getMaxTaskOutputLength(): number { 6: const result = validateBoundedIntEnvVar( 7: 'TASK_MAX_OUTPUT_LENGTH', 8: process.env.TASK_MAX_OUTPUT_LENGTH, 9: TASK_MAX_OUTPUT_DEFAULT, 10: TASK_MAX_OUTPUT_UPPER_LIMIT, 11: ) 12: return result.effective 13: } 14: export function formatTaskOutput( 15: output: string, 16: taskId: string, 17: ): { content: string; wasTruncated: boolean } { 18: const maxLen = getMaxTaskOutputLength() 19: if (output.length <= maxLen) { 20: return { content: output, wasTruncated: false } 21: } 22: const filePath = getTaskOutputPath(taskId) 23: const header = `[Truncated. Full output: ${filePath}]\n\n` 24: const availableSpace = maxLen - header.length 25: const truncated = output.slice(-availableSpace) 26: return { content: header + truncated, wasTruncated: true } 27: }

File: src/utils/task/sdkProgress.ts

typescript 1: import type { SdkWorkflowProgress } from '../../types/tools.js' 2: import { enqueueSdkEvent } from '../sdkEventQueue.js' 3: export function emitTaskProgress(params: { 4: taskId: string 5: toolUseId: string | undefined 6: description: string 7: startTime: number 8: totalTokens: number 9: toolUses: number 10: lastToolName?: string 11: summary?: string 12: workflowProgress?: SdkWorkflowProgress[] 13: }): void { 14: enqueueSdkEvent({ 15: type: 'system', 16: subtype: 'task_progress', 17: task_id: params.taskId, 18: tool_use_id: params.toolUseId, 19: description: params.description, 20: usage: { 21: total_tokens: params.totalTokens, 22: tool_uses: params.toolUses, 23: duration_ms: Date.now() - params.startTime, 24: }, 25: last_tool_name: params.lastToolName, 26: summary: params.summary, 27: workflow_progress: params.workflowProgress, 28: }) 29: }

File: src/utils/task/TaskOutput.ts

typescript 1: import { unlink } from 'fs/promises' 2: import { CircularBuffer } from '../CircularBuffer.js' 3: import { logForDebugging } from '../debug.js' 4: import { readFileRange, tailFile } from '../fsOperations.js' 5: import { getMaxOutputLength } from '../shell/outputLimits.js' 6: import { safeJoinLines } from '../stringUtils.js' 7: import { DiskTaskOutput, getTaskOutputPath } from './diskOutput.js' 8: const DEFAULT_MAX_MEMORY = 8 * 1024 * 1024 9: const POLL_INTERVAL_MS = 1000 10: const PROGRESS_TAIL_BYTES = 4096 11: type ProgressCallback = ( 12: lastLines: string, 13: allLines: string, 14: totalLines: number, 15: totalBytes: number, 16: isIncomplete: boolean, 17: ) => void 18: export class TaskOutput { 19: readonly taskId: string 20: readonly path: string 21: readonly stdoutToFile: boolean 22: #stdoutBuffer = '' 23: #stderrBuffer = '' 24: #disk: DiskTaskOutput | null = null 25: #recentLines = new CircularBuffer<string>(1000) 26: #totalLines = 0 27: #totalBytes = 0 28: #maxMemory: number 29: #onProgress: ProgressCallback | null 30: /** Set by getStdout() — true when the file was fully read (≤ maxOutputLength). */ 31: #outputFileRedundant = false 32: /** Set by getStdout() — total file size in bytes. */ 33: #outputFileSize = 0 34: // --- Shared poller state --- 35: /** Registry of all file-mode TaskOutput instances with onProgress callbacks. */ 36: static #registry = new Map<string, TaskOutput>() 37: /** Subset of #registry currently being polled (visibility-driven by React). */ 38: static #activePolling = new Map<string, TaskOutput>() 39: static #pollInterval: ReturnType<typeof setInterval> | null = null 40: constructor( 41: taskId: string, 42: onProgress: ProgressCallback | null, 43: stdoutToFile = false, 44: maxMemory: number = DEFAULT_MAX_MEMORY, 45: ) { 46: this.taskId = taskId 47: this.path = getTaskOutputPath(taskId) 48: this.stdoutToFile = stdoutToFile 49: this.#maxMemory = maxMemory 50: this.#onProgress = onProgress 51: // Register for polling when stdout goes to a file and progress is needed. 52: // Actual polling is started/stopped by React via startPolling/stopPolling. 53: if (stdoutToFile && onProgress) { 54: TaskOutput.#registry.set(taskId, this) 55: } 56: } 57: /** 58: * Begin polling the output file for progress. Called from React 59: * useEffect when the progress component mounts. 60: */ 61: static startPolling(taskId: string): void { 62: const instance = TaskOutput.#registry.get(taskId) 63: if (!instance || !instance.#onProgress) { 64: return 65: } 66: TaskOutput.#activePolling.set(taskId, instance) 67: if (!TaskOutput.#pollInterval) { 68: TaskOutput.#pollInterval = setInterval(TaskOutput.#tick, POLL_INTERVAL_MS) 69: TaskOutput.#pollInterval.unref() 70: } 71: } 72: /** 73: * Stop polling the output file. Called from React useEffect cleanup 74: * when the progress component unmounts. 75: */ 76: static stopPolling(taskId: string): void { 77: TaskOutput.#activePolling.delete(taskId) 78: if (TaskOutput.#activePolling.size === 0 && TaskOutput.#pollInterval) { 79: clearInterval(TaskOutput.#pollInterval) 80: TaskOutput.#pollInterval = null 81: } 82: } 83: /** 84: * Shared tick: reads the file tail for every actively-polled task. 85: * Non-async body (.then) to avoid stacking if I/O is slow. 86: */ 87: static #tick(): void { 88: for (const [, entry] of TaskOutput.#activePolling) { 89: if (!entry.#onProgress) { 90: continue 91: } 92: void tailFile(entry.path, PROGRESS_TAIL_BYTES).then( 93: ({ content, bytesRead, bytesTotal }) => { 94: if (!entry.#onProgress) { 95: return 96: } 97: // Always call onProgress even when content is empty, so the 98: // progress loop wakes up and can check for backgrounding. 99: // Commands like `git log -S` produce no output for long periods. 100: if (!content) { 101: entry.#onProgress('', '', entry.#totalLines, bytesTotal, false) 102: return 103: } 104: // Count all newlines in the tail and capture slice points for the 105: // last 5 and last 100 lines. Uncapped so extrapolation stays accurate 106: // for dense output (short lines → >100 newlines in 4KB). 107: let pos = content.length 108: let n5 = 0 109: let n100 = 0 110: let lineCount = 0 111: while (pos > 0) { 112: pos = content.lastIndexOf('\n', pos - 1) 113: lineCount++ 114: if (lineCount === 5) n5 = pos <= 0 ? 0 : pos + 1 115: if (lineCount === 100) n100 = pos <= 0 ? 0 : pos + 1 116: } 117: const totalLines = 118: bytesRead >= bytesTotal 119: ? lineCount 120: : Math.max( 121: entry.#totalLines, 122: Math.round((bytesTotal / bytesRead) * lineCount), 123: ) 124: entry.#totalLines = totalLines 125: entry.#totalBytes = bytesTotal 126: entry.#onProgress( 127: content.slice(n5), 128: content.slice(n100), 129: totalLines, 130: bytesTotal, 131: bytesRead < bytesTotal, 132: ) 133: }, 134: () => { 135: }, 136: ) 137: } 138: } 139: writeStdout(data: string): void { 140: this.#writeBuffered(data, false) 141: } 142: writeStderr(data: string): void { 143: this.#writeBuffered(data, true) 144: } 145: #writeBuffered(data: string, isStderr: boolean): void { 146: this.#totalBytes += data.length 147: this.#updateProgress(data) 148: if (this.#disk) { 149: this.#disk.append(isStderr ? `[stderr] ${data}` : data) 150: return 151: } 152: const totalMem = 153: this.#stdoutBuffer.length + this.#stderrBuffer.length + data.length 154: if (totalMem > this.#maxMemory) { 155: this.#spillToDisk(isStderr ? data : null, isStderr ? null : data) 156: return 157: } 158: if (isStderr) { 159: this.#stderrBuffer += data 160: } else { 161: this.#stdoutBuffer += data 162: } 163: } 164: #updateProgress(data: string): void { 165: const MAX_PROGRESS_BYTES = 4096 166: const MAX_PROGRESS_LINES = 100 167: let lineCount = 0 168: const lines: string[] = [] 169: let extractedBytes = 0 170: let pos = data.length 171: while (pos > 0) { 172: const prev = data.lastIndexOf('\n', pos - 1) 173: if (prev === -1) { 174: break 175: } 176: lineCount++ 177: if ( 178: lines.length < MAX_PROGRESS_LINES && 179: extractedBytes < MAX_PROGRESS_BYTES 180: ) { 181: const lineLen = pos - prev - 1 182: if (lineLen > 0 && lineLen <= MAX_PROGRESS_BYTES - extractedBytes) { 183: const line = data.slice(prev + 1, pos) 184: if (line.trim()) { 185: lines.push(Buffer.from(line).toString()) 186: extractedBytes += lineLen 187: } 188: } 189: } 190: pos = prev 191: } 192: this.#totalLines += lineCount 193: for (let i = lines.length - 1; i >= 0; i--) { 194: this.#recentLines.add(lines[i]!) 195: } 196: if (this.#onProgress && lines.length > 0) { 197: const recent = this.#recentLines.getRecent(5) 198: this.#onProgress( 199: safeJoinLines(recent, '\n'), 200: safeJoinLines(this.#recentLines.getRecent(100), '\n'), 201: this.#totalLines, 202: this.#totalBytes, 203: this.#disk !== null, 204: ) 205: } 206: } 207: #spillToDisk(stderrChunk: string | null, stdoutChunk: string | null): void { 208: this.#disk = new DiskTaskOutput(this.taskId) 209: if (this.#stdoutBuffer) { 210: this.#disk.append(this.#stdoutBuffer) 211: this.#stdoutBuffer = '' 212: } 213: if (this.#stderrBuffer) { 214: this.#disk.append(`[stderr] ${this.#stderrBuffer}`) 215: this.#stderrBuffer = '' 216: } 217: // Write the chunk that triggered overflow 218: if (stdoutChunk) { 219: this.#disk.append(stdoutChunk) 220: } 221: if (stderrChunk) { 222: this.#disk.append(`[stderr] ${stderrChunk}`) 223: } 224: } 225: /** 226: * Get stdout. In file mode, reads from the output file. 227: * In pipe mode, returns the in-memory buffer or tail from CircularBuffer. 228: */ 229: async getStdout(): Promise<string> { 230: if (this.stdoutToFile) { 231: return this.#readStdoutFromFile() 232: } 233: // Pipe mode (hooks) — use in-memory data 234: if (this.#disk) { 235: const recent = this.#recentLines.getRecent(5) 236: const tail = safeJoinLines(recent, '\n') 237: const sizeKB = Math.round(this.#totalBytes / 1024) 238: const notice = `\nOutput truncated (${sizeKB}KB total). Full output saved to: ${this.path}` 239: return tail ? tail + notice : notice.trimStart() 240: } 241: return this.#stdoutBuffer 242: } 243: async #readStdoutFromFile(): Promise<string> { 244: const maxBytes = getMaxOutputLength() 245: try { 246: const result = await readFileRange(this.path, 0, maxBytes) 247: if (!result) { 248: this.#outputFileRedundant = true 249: return '' 250: } 251: const { content, bytesRead, bytesTotal } = result 252: // If the file fits, it's fully captured inline and can be deleted. 253: this.#outputFileSize = bytesTotal 254: this.#outputFileRedundant = bytesTotal <= bytesRead 255: return content 256: } catch (err) { 257: const code = 258: err instanceof Error && 'code' in err ? String(err.code) : 'unknown' 259: logForDebugging( 260: `TaskOutput.#readStdoutFromFile: failed to read ${this.path} (${code}): ${err}`, 261: ) 262: return `<bash output unavailable: output file ${this.path} could not be read (${code}). This usually means another Claude Code process in the same project deleted it during startup cleanup.>` 263: } 264: } 265: getStderr(): string { 266: if (this.#disk) { 267: return '' 268: } 269: return this.#stderrBuffer 270: } 271: get isOverflowed(): boolean { 272: return this.#disk !== null 273: } 274: get totalLines(): number { 275: return this.#totalLines 276: } 277: get totalBytes(): number { 278: return this.#totalBytes 279: } 280: /** 281: * True after getStdout() when the output file was fully read. 282: * The file content is redundant (fully in ExecResult.stdout) and can be deleted. 283: */ 284: get outputFileRedundant(): boolean { 285: return this.#outputFileRedundant 286: } 287: /** Total file size in bytes, set after getStdout() reads the file. */ 288: get outputFileSize(): number { 289: return this.#outputFileSize 290: } 291: /** Force all buffered content to disk. Call when backgrounding. */ 292: spillToDisk(): void { 293: if (!this.#disk) { 294: this.#spillToDisk(null, null) 295: } 296: } 297: async flush(): Promise<void> { 298: await this.#disk?.flush() 299: } 300: /** Delete the output file (fire-and-forget safe). */ 301: async deleteOutputFile(): Promise<void> { 302: try { 303: await unlink(this.path) 304: } catch { 305: // File may already be deleted or not exist 306: } 307: } 308: clear(): void { 309: this.#stdoutBuffer = '' 310: this.#stderrBuffer = '' 311: this.#recentLines.clear() 312: this.#onProgress = null 313: this.#disk?.cancel() 314: TaskOutput.stopPolling(this.taskId) 315: TaskOutput.#registry.delete(this.taskId) 316: } 317: }

File: src/utils/telemetry/betaSessionTracing.ts

typescript 1: import type { Span } from '@opentelemetry/api' 2: import { createHash } from 'crypto' 3: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5: import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' 6: import type { AssistantMessage, UserMessage } from '../../types/message.js' 7: import { isEnvTruthy } from '../envUtils.js' 8: import { jsonParse, jsonStringify } from '../slowOperations.js' 9: import { logOTelEvent } from './events.js' 10: type APIMessage = UserMessage | AssistantMessage 11: const seenHashes = new Set<string>() 12: const lastReportedMessageHash = new Map<string, string>() 13: export function clearBetaTracingState(): void { 14: seenHashes.clear() 15: lastReportedMessageHash.clear() 16: } 17: const MAX_CONTENT_SIZE = 60 * 1024 18: export function isBetaTracingEnabled(): boolean { 19: const baseEnabled = 20: isEnvTruthy(process.env.ENABLE_BETA_TRACING_DETAILED) && 21: Boolean(process.env.BETA_TRACING_ENDPOINT) 22: if (!baseEnabled) { 23: return false 24: } 25: if (process.env.USER_TYPE !== 'ant') { 26: return ( 27: getIsNonInteractiveSession() || 28: getFeatureValue_CACHED_MAY_BE_STALE('tengu_trace_lantern', false) 29: ) 30: } 31: return true 32: } 33: export function truncateContent( 34: content: string, 35: maxSize: number = MAX_CONTENT_SIZE, 36: ): { content: string; truncated: boolean } { 37: if (content.length <= maxSize) { 38: return { content, truncated: false } 39: } 40: return { 41: content: 42: content.slice(0, maxSize) + 43: '\n\n[TRUNCATED - Content exceeds 60KB limit]', 44: truncated: true, 45: } 46: } 47: function shortHash(content: string): string { 48: return createHash('sha256').update(content).digest('hex').slice(0, 12) 49: } 50: function hashSystemPrompt(systemPrompt: string): string { 51: return `sp_${shortHash(systemPrompt)}` 52: } 53: function hashMessage(message: APIMessage): string { 54: const content = jsonStringify(message.message.content) 55: return `msg_${shortHash(content)}` 56: } 57: const SYSTEM_REMINDER_REGEX = 58: /^<system-reminder>\n?([\s\S]*?)\n?<\/system-reminder>$/ 59: function extractSystemReminderContent(text: string): string | null { 60: const match = text.trim().match(SYSTEM_REMINDER_REGEX) 61: return match && match[1] ? match[1].trim() : null 62: } 63: interface FormattedMessages { 64: contextParts: string[] 65: systemReminders: string[] 66: } 67: function formatMessagesForContext(messages: UserMessage[]): FormattedMessages { 68: const contextParts: string[] = [] 69: const systemReminders: string[] = [] 70: for (const message of messages) { 71: const content = message.message.content 72: if (typeof content === 'string') { 73: const reminderContent = extractSystemReminderContent(content) 74: if (reminderContent) { 75: systemReminders.push(reminderContent) 76: } else { 77: contextParts.push(`[USER]\n${content}`) 78: } 79: } else if (Array.isArray(content)) { 80: for (const block of content) { 81: if (block.type === 'text') { 82: const reminderContent = extractSystemReminderContent(block.text) 83: if (reminderContent) { 84: systemReminders.push(reminderContent) 85: } else { 86: contextParts.push(`[USER]\n${block.text}`) 87: } 88: } else if (block.type === 'tool_result') { 89: const resultContent = 90: typeof block.content === 'string' 91: ? block.content 92: : jsonStringify(block.content) 93: const reminderContent = extractSystemReminderContent(resultContent) 94: if (reminderContent) { 95: systemReminders.push(reminderContent) 96: } else { 97: contextParts.push( 98: `[TOOL RESULT: ${block.tool_use_id}]\n${resultContent}`, 99: ) 100: } 101: } 102: } 103: } 104: } 105: return { contextParts, systemReminders } 106: } 107: export interface LLMRequestNewContext { 108: systemPrompt?: string 109: querySource?: string 110: tools?: string 111: } 112: export function addBetaInteractionAttributes( 113: span: Span, 114: userPrompt: string, 115: ): void { 116: if (!isBetaTracingEnabled()) { 117: return 118: } 119: const { content: truncatedPrompt, truncated } = truncateContent( 120: `[USER PROMPT]\n${userPrompt}`, 121: ) 122: span.setAttributes({ 123: new_context: truncatedPrompt, 124: ...(truncated && { 125: new_context_truncated: true, 126: new_context_original_length: userPrompt.length, 127: }), 128: }) 129: } 130: export function addBetaLLMRequestAttributes( 131: span: Span, 132: newContext?: LLMRequestNewContext, 133: messagesForAPI?: APIMessage[], 134: ): void { 135: if (!isBetaTracingEnabled()) { 136: return 137: } 138: if (newContext?.systemPrompt) { 139: const promptHash = hashSystemPrompt(newContext.systemPrompt) 140: const preview = newContext.systemPrompt.slice(0, 500) 141: span.setAttribute('system_prompt_hash', promptHash) 142: span.setAttribute('system_prompt_preview', preview) 143: span.setAttribute('system_prompt_length', newContext.systemPrompt.length) 144: if (!seenHashes.has(promptHash)) { 145: seenHashes.add(promptHash) 146: const { content: truncatedPrompt, truncated } = truncateContent( 147: newContext.systemPrompt, 148: ) 149: void logOTelEvent('system_prompt', { 150: system_prompt_hash: promptHash, 151: system_prompt: truncatedPrompt, 152: system_prompt_length: String(newContext.systemPrompt.length), 153: ...(truncated && { system_prompt_truncated: 'true' }), 154: }) 155: } 156: } 157: if (newContext?.tools) { 158: try { 159: const toolsArray = jsonParse(newContext.tools) as Record< 160: string, 161: unknown 162: >[] 163: const toolsWithHashes = toolsArray.map(tool => { 164: const toolJson = jsonStringify(tool) 165: const toolHash = shortHash(toolJson) 166: return { 167: name: typeof tool.name === 'string' ? tool.name : 'unknown', 168: hash: toolHash, 169: json: toolJson, 170: } 171: }) 172: span.setAttribute( 173: 'tools', 174: jsonStringify( 175: toolsWithHashes.map(({ name, hash }) => ({ name, hash })), 176: ), 177: ) 178: span.setAttribute('tools_count', toolsWithHashes.length) 179: for (const { name, hash, json } of toolsWithHashes) { 180: if (!seenHashes.has(`tool_${hash}`)) { 181: seenHashes.add(`tool_${hash}`) 182: const { content: truncatedTool, truncated } = truncateContent(json) 183: void logOTelEvent('tool', { 184: tool_name: sanitizeToolNameForAnalytics(name), 185: tool_hash: hash, 186: tool: truncatedTool, 187: ...(truncated && { tool_truncated: 'true' }), 188: }) 189: } 190: } 191: } catch { 192: span.setAttribute('tools_parse_error', true) 193: } 194: } 195: if (messagesForAPI && messagesForAPI.length > 0 && newContext?.querySource) { 196: const querySource = newContext.querySource 197: const lastHash = lastReportedMessageHash.get(querySource) 198: let startIndex = 0 199: if (lastHash) { 200: for (let i = 0; i < messagesForAPI.length; i++) { 201: const msg = messagesForAPI[i] 202: if (msg && hashMessage(msg) === lastHash) { 203: startIndex = i + 1 204: break 205: } 206: } 207: } 208: const newMessages = messagesForAPI 209: .slice(startIndex) 210: .filter((m): m is UserMessage => m.type === 'user') 211: if (newMessages.length > 0) { 212: const { contextParts, systemReminders } = 213: formatMessagesForContext(newMessages) 214: if (contextParts.length > 0) { 215: const fullContext = contextParts.join('\n\n---\n\n') 216: const { content: truncatedContext, truncated } = 217: truncateContent(fullContext) 218: span.setAttributes({ 219: new_context: truncatedContext, 220: new_context_message_count: newMessages.length, 221: ...(truncated && { 222: new_context_truncated: true, 223: new_context_original_length: fullContext.length, 224: }), 225: }) 226: } 227: if (systemReminders.length > 0) { 228: const fullReminders = systemReminders.join('\n\n---\n\n') 229: const { content: truncatedReminders, truncated: remindersTruncated } = 230: truncateContent(fullReminders) 231: span.setAttributes({ 232: system_reminders: truncatedReminders, 233: system_reminders_count: systemReminders.length, 234: ...(remindersTruncated && { 235: system_reminders_truncated: true, 236: system_reminders_original_length: fullReminders.length, 237: }), 238: }) 239: } 240: const lastMessage = messagesForAPI[messagesForAPI.length - 1] 241: if (lastMessage) { 242: lastReportedMessageHash.set(querySource, hashMessage(lastMessage)) 243: } 244: } 245: } 246: } 247: export function addBetaLLMResponseAttributes( 248: endAttributes: Record<string, string | number | boolean>, 249: metadata?: { 250: modelOutput?: string 251: thinkingOutput?: string 252: }, 253: ): void { 254: if (!isBetaTracingEnabled() || !metadata) { 255: return 256: } 257: if (metadata.modelOutput !== undefined) { 258: const { content: modelOutput, truncated: outputTruncated } = 259: truncateContent(metadata.modelOutput) 260: endAttributes['response.model_output'] = modelOutput 261: if (outputTruncated) { 262: endAttributes['response.model_output_truncated'] = true 263: endAttributes['response.model_output_original_length'] = 264: metadata.modelOutput.length 265: } 266: } 267: if ( 268: process.env.USER_TYPE === 'ant' && 269: metadata.thinkingOutput !== undefined 270: ) { 271: const { content: thinkingOutput, truncated: thinkingTruncated } = 272: truncateContent(metadata.thinkingOutput) 273: endAttributes['response.thinking_output'] = thinkingOutput 274: if (thinkingTruncated) { 275: endAttributes['response.thinking_output_truncated'] = true 276: endAttributes['response.thinking_output_original_length'] = 277: metadata.thinkingOutput.length 278: } 279: } 280: } 281: export function addBetaToolInputAttributes( 282: span: Span, 283: toolName: string, 284: toolInput: string, 285: ): void { 286: if (!isBetaTracingEnabled()) { 287: return 288: } 289: const { content: truncatedInput, truncated } = truncateContent( 290: `[TOOL INPUT: ${toolName}]\n${toolInput}`, 291: ) 292: span.setAttributes({ 293: tool_input: truncatedInput, 294: ...(truncated && { 295: tool_input_truncated: true, 296: tool_input_original_length: toolInput.length, 297: }), 298: }) 299: } 300: export function addBetaToolResultAttributes( 301: endAttributes: Record<string, string | number | boolean>, 302: toolName: string | number | boolean, 303: toolResult: string, 304: ): void { 305: if (!isBetaTracingEnabled()) { 306: return 307: } 308: const { content: truncatedResult, truncated } = truncateContent( 309: `[TOOL RESULT: ${toolName}]\n${toolResult}`, 310: ) 311: endAttributes['new_context'] = truncatedResult 312: if (truncated) { 313: endAttributes['new_context_truncated'] = true 314: endAttributes['new_context_original_length'] = toolResult.length 315: } 316: }

File: src/utils/telemetry/bigqueryExporter.ts

typescript 1: import type { Attributes, HrTime } from '@opentelemetry/api' 2: import { type ExportResult, ExportResultCode } from '@opentelemetry/core' 3: import { 4: AggregationTemporality, 5: type MetricData, 6: type DataPoint as OTelDataPoint, 7: type PushMetricExporter, 8: type ResourceMetrics, 9: } from '@opentelemetry/sdk-metrics' 10: import axios from 'axios' 11: import { checkMetricsEnabled } from 'src/services/api/metricsOptOut.js' 12: import { getIsNonInteractiveSession } from '../../bootstrap/state.js' 13: import { getSubscriptionType, isClaudeAISubscriber } from '../auth.js' 14: import { checkHasTrustDialogAccepted } from '../config.js' 15: import { logForDebugging } from '../debug.js' 16: import { errorMessage, toError } from '../errors.js' 17: import { getAuthHeaders } from '../http.js' 18: import { logError } from '../log.js' 19: import { jsonStringify } from '../slowOperations.js' 20: import { getClaudeCodeUserAgent } from '../userAgent.js' 21: type DataPoint = { 22: attributes: Record<string, string> 23: value: number 24: timestamp: string 25: } 26: type Metric = { 27: name: string 28: description?: string 29: unit?: string 30: data_points: DataPoint[] 31: } 32: type InternalMetricsPayload = { 33: resource_attributes: Record<string, string> 34: metrics: Metric[] 35: } 36: export class BigQueryMetricsExporter implements PushMetricExporter { 37: private readonly endpoint: string 38: private readonly timeout: number 39: private pendingExports: Promise<void>[] = [] 40: private isShutdown = false 41: constructor(options: { timeout?: number } = {}) { 42: const defaultEndpoint = 'https://api.anthropic.com/api/claude_code/metrics' 43: if ( 44: process.env.USER_TYPE === 'ant' && 45: process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT 46: ) { 47: this.endpoint = 48: process.env.ANT_CLAUDE_CODE_METRICS_ENDPOINT + 49: '/api/claude_code/metrics' 50: } else { 51: this.endpoint = defaultEndpoint 52: } 53: this.timeout = options.timeout || 5000 54: } 55: async export( 56: metrics: ResourceMetrics, 57: resultCallback: (result: ExportResult) => void, 58: ): Promise<void> { 59: if (this.isShutdown) { 60: resultCallback({ 61: code: ExportResultCode.FAILED, 62: error: new Error('Exporter has been shutdown'), 63: }) 64: return 65: } 66: const exportPromise = this.doExport(metrics, resultCallback) 67: this.pendingExports.push(exportPromise) 68: void exportPromise.finally(() => { 69: const index = this.pendingExports.indexOf(exportPromise) 70: if (index > -1) { 71: void this.pendingExports.splice(index, 1) 72: } 73: }) 74: } 75: private async doExport( 76: metrics: ResourceMetrics, 77: resultCallback: (result: ExportResult) => void, 78: ): Promise<void> { 79: try { 80: const hasTrust = 81: checkHasTrustDialogAccepted() || getIsNonInteractiveSession() 82: if (!hasTrust) { 83: logForDebugging( 84: 'BigQuery metrics export: trust not established, skipping', 85: ) 86: resultCallback({ code: ExportResultCode.SUCCESS }) 87: return 88: } 89: const metricsStatus = await checkMetricsEnabled() 90: if (!metricsStatus.enabled) { 91: logForDebugging('Metrics export disabled by organization setting') 92: resultCallback({ code: ExportResultCode.SUCCESS }) 93: return 94: } 95: const payload = this.transformMetricsForInternal(metrics) 96: const authResult = getAuthHeaders() 97: if (authResult.error) { 98: logForDebugging(`Metrics export failed: ${authResult.error}`) 99: resultCallback({ 100: code: ExportResultCode.FAILED, 101: error: new Error(authResult.error), 102: }) 103: return 104: } 105: const headers: Record<string, string> = { 106: 'Content-Type': 'application/json', 107: 'User-Agent': getClaudeCodeUserAgent(), 108: ...authResult.headers, 109: } 110: const response = await axios.post(this.endpoint, payload, { 111: timeout: this.timeout, 112: headers, 113: }) 114: logForDebugging('BigQuery metrics exported successfully') 115: logForDebugging( 116: `BigQuery API Response: ${jsonStringify(response.data, null, 2)}`, 117: ) 118: resultCallback({ code: ExportResultCode.SUCCESS }) 119: } catch (error) { 120: logForDebugging(`BigQuery metrics export failed: ${errorMessage(error)}`) 121: logError(error) 122: resultCallback({ 123: code: ExportResultCode.FAILED, 124: error: toError(error), 125: }) 126: } 127: } 128: private transformMetricsForInternal( 129: metrics: ResourceMetrics, 130: ): InternalMetricsPayload { 131: const attrs = metrics.resource.attributes 132: const resourceAttributes: Record<string, string> = { 133: 'service.name': (attrs['service.name'] as string) || 'claude-code', 134: 'service.version': (attrs['service.version'] as string) || 'unknown', 135: 'os.type': (attrs['os.type'] as string) || 'unknown', 136: 'os.version': (attrs['os.version'] as string) || 'unknown', 137: 'host.arch': (attrs['host.arch'] as string) || 'unknown', 138: 'aggregation.temporality': 139: this.selectAggregationTemporality() === AggregationTemporality.DELTA 140: ? 'delta' 141: : 'cumulative', 142: } 143: if (attrs['wsl.version']) { 144: resourceAttributes['wsl.version'] = attrs['wsl.version'] as string 145: } 146: if (isClaudeAISubscriber()) { 147: resourceAttributes['user.customer_type'] = 'claude_ai' 148: const subscriptionType = getSubscriptionType() 149: if (subscriptionType) { 150: resourceAttributes['user.subscription_type'] = subscriptionType 151: } 152: } else { 153: resourceAttributes['user.customer_type'] = 'api' 154: } 155: const transformed = { 156: resource_attributes: resourceAttributes, 157: metrics: metrics.scopeMetrics.flatMap(scopeMetric => 158: scopeMetric.metrics.map(metric => ({ 159: name: metric.descriptor.name, 160: description: metric.descriptor.description, 161: unit: metric.descriptor.unit, 162: data_points: this.extractDataPoints(metric), 163: })), 164: ), 165: } 166: return transformed 167: } 168: private extractDataPoints(metric: MetricData): DataPoint[] { 169: const dataPoints = metric.dataPoints || [] 170: return dataPoints 171: .filter( 172: (point): point is OTelDataPoint<number> => 173: typeof point.value === 'number', 174: ) 175: .map(point => ({ 176: attributes: this.convertAttributes(point.attributes), 177: value: point.value, 178: timestamp: this.hrTimeToISOString( 179: point.endTime || point.startTime || [Date.now() / 1000, 0], 180: ), 181: })) 182: } 183: async shutdown(): Promise<void> { 184: this.isShutdown = true 185: await this.forceFlush() 186: logForDebugging('BigQuery metrics exporter shutdown complete') 187: } 188: async forceFlush(): Promise<void> { 189: await Promise.all(this.pendingExports) 190: logForDebugging('BigQuery metrics exporter flush complete') 191: } 192: private convertAttributes( 193: attributes: Attributes | undefined, 194: ): Record<string, string> { 195: const result: Record<string, string> = {} 196: if (attributes) { 197: for (const [key, value] of Object.entries(attributes)) { 198: if (value !== undefined && value !== null) { 199: result[key] = String(value) 200: } 201: } 202: } 203: return result 204: } 205: private hrTimeToISOString(hrTime: HrTime): string { 206: const [seconds, nanoseconds] = hrTime 207: const date = new Date(seconds * 1000 + nanoseconds / 1000000) 208: return date.toISOString() 209: } 210: selectAggregationTemporality(): AggregationTemporality { 211: return AggregationTemporality.DELTA 212: } 213: }

File: src/utils/telemetry/events.ts

typescript 1: import type { Attributes } from '@opentelemetry/api' 2: import { getEventLogger, getPromptId } from 'src/bootstrap/state.js' 3: import { logForDebugging } from '../debug.js' 4: import { isEnvTruthy } from '../envUtils.js' 5: import { getTelemetryAttributes } from '../telemetryAttributes.js' 6: let eventSequence = 0 7: let hasWarnedNoEventLogger = false 8: function isUserPromptLoggingEnabled() { 9: return isEnvTruthy(process.env.OTEL_LOG_USER_PROMPTS) 10: } 11: export function redactIfDisabled(content: string): string { 12: return isUserPromptLoggingEnabled() ? content : '<REDACTED>' 13: } 14: export async function logOTelEvent( 15: eventName: string, 16: metadata: { [key: string]: string | undefined } = {}, 17: ): Promise<void> { 18: const eventLogger = getEventLogger() 19: if (!eventLogger) { 20: if (!hasWarnedNoEventLogger) { 21: hasWarnedNoEventLogger = true 22: logForDebugging( 23: `[3P telemetry] Event dropped (no event logger initialized): ${eventName}`, 24: { level: 'warn' }, 25: ) 26: } 27: return 28: } 29: if (process.env.NODE_ENV === 'test') { 30: return 31: } 32: const attributes: Attributes = { 33: ...getTelemetryAttributes(), 34: 'event.name': eventName, 35: 'event.timestamp': new Date().toISOString(), 36: 'event.sequence': eventSequence++, 37: } 38: const promptId = getPromptId() 39: if (promptId) { 40: attributes['prompt.id'] = promptId 41: } 42: const workspaceDir = process.env.CLAUDE_CODE_WORKSPACE_HOST_PATHS 43: if (workspaceDir) { 44: attributes['workspace.host_paths'] = workspaceDir.split('|') 45: } 46: for (const [key, value] of Object.entries(metadata)) { 47: if (value !== undefined) { 48: attributes[key] = value 49: } 50: } 51: eventLogger.emit({ 52: body: `claude_code.${eventName}`, 53: attributes, 54: }) 55: }

File: src/utils/telemetry/instrumentation.ts

typescript 1: import { DiagLogLevel, diag, trace } from '@opentelemetry/api' 2: import { logs } from '@opentelemetry/api-logs' 3: import { 4: envDetector, 5: hostDetector, 6: osDetector, 7: resourceFromAttributes, 8: } from '@opentelemetry/resources' 9: import { 10: BatchLogRecordProcessor, 11: ConsoleLogRecordExporter, 12: LoggerProvider, 13: } from '@opentelemetry/sdk-logs' 14: import { 15: ConsoleMetricExporter, 16: MeterProvider, 17: PeriodicExportingMetricReader, 18: } from '@opentelemetry/sdk-metrics' 19: import { 20: BasicTracerProvider, 21: BatchSpanProcessor, 22: ConsoleSpanExporter, 23: } from '@opentelemetry/sdk-trace-base' 24: import { 25: ATTR_SERVICE_NAME, 26: ATTR_SERVICE_VERSION, 27: SEMRESATTRS_HOST_ARCH, 28: } from '@opentelemetry/semantic-conventions' 29: import { HttpsProxyAgent } from 'https-proxy-agent' 30: import { 31: getLoggerProvider, 32: getMeterProvider, 33: getTracerProvider, 34: setEventLogger, 35: setLoggerProvider, 36: setMeterProvider, 37: setTracerProvider, 38: } from 'src/bootstrap/state.js' 39: import { 40: getOtelHeadersFromHelper, 41: getSubscriptionType, 42: is1PApiCustomer, 43: isClaudeAISubscriber, 44: } from 'src/utils/auth.js' 45: import { getPlatform, getWslVersion } from 'src/utils/platform.js' 46: import { getCACertificates } from '../caCerts.js' 47: import { registerCleanup } from '../cleanupRegistry.js' 48: import { getHasFormattedOutput, logForDebugging } from '../debug.js' 49: import { isEnvTruthy } from '../envUtils.js' 50: import { errorMessage } from '../errors.js' 51: import { getMTLSConfig } from '../mtls.js' 52: import { getProxyUrl, shouldBypassProxy } from '../proxy.js' 53: import { getSettings_DEPRECATED } from '../settings/settings.js' 54: import { jsonStringify } from '../slowOperations.js' 55: import { profileCheckpoint } from '../startupProfiler.js' 56: import { isBetaTracingEnabled } from './betaSessionTracing.js' 57: import { BigQueryMetricsExporter } from './bigqueryExporter.js' 58: import { ClaudeCodeDiagLogger } from './logger.js' 59: import { initializePerfettoTracing } from './perfettoTracing.js' 60: import { 61: endInteractionSpan, 62: isEnhancedTelemetryEnabled, 63: } from './sessionTracing.js' 64: const DEFAULT_METRICS_EXPORT_INTERVAL_MS = 60000 65: const DEFAULT_LOGS_EXPORT_INTERVAL_MS = 5000 66: const DEFAULT_TRACES_EXPORT_INTERVAL_MS = 5000 67: class TelemetryTimeoutError extends Error {} 68: function telemetryTimeout(ms: number, message: string): Promise<never> { 69: return new Promise((_, reject) => { 70: setTimeout( 71: (rej: (e: Error) => void, msg: string) => 72: rej(new TelemetryTimeoutError(msg)), 73: ms, 74: reject, 75: message, 76: ).unref() 77: }) 78: } 79: export function bootstrapTelemetry() { 80: if (process.env.USER_TYPE === 'ant') { 81: if (process.env.ANT_OTEL_METRICS_EXPORTER) { 82: process.env.OTEL_METRICS_EXPORTER = process.env.ANT_OTEL_METRICS_EXPORTER 83: } 84: if (process.env.ANT_OTEL_LOGS_EXPORTER) { 85: process.env.OTEL_LOGS_EXPORTER = process.env.ANT_OTEL_LOGS_EXPORTER 86: } 87: if (process.env.ANT_OTEL_TRACES_EXPORTER) { 88: process.env.OTEL_TRACES_EXPORTER = process.env.ANT_OTEL_TRACES_EXPORTER 89: } 90: if (process.env.ANT_OTEL_EXPORTER_OTLP_PROTOCOL) { 91: process.env.OTEL_EXPORTER_OTLP_PROTOCOL = 92: process.env.ANT_OTEL_EXPORTER_OTLP_PROTOCOL 93: } 94: if (process.env.ANT_OTEL_EXPORTER_OTLP_ENDPOINT) { 95: process.env.OTEL_EXPORTER_OTLP_ENDPOINT = 96: process.env.ANT_OTEL_EXPORTER_OTLP_ENDPOINT 97: } 98: if (process.env.ANT_OTEL_EXPORTER_OTLP_HEADERS) { 99: process.env.OTEL_EXPORTER_OTLP_HEADERS = 100: process.env.ANT_OTEL_EXPORTER_OTLP_HEADERS 101: } 102: } 103: if (!process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE) { 104: process.env.OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE = 'delta' 105: } 106: } 107: export function parseExporterTypes(value: string | undefined): string[] { 108: return (value || '') 109: .trim() 110: .split(',') 111: .filter(Boolean) 112: .map(t => t.trim()) 113: .filter(t => t !== 'none') 114: } 115: async function getOtlpReaders() { 116: const exporterTypes = parseExporterTypes(process.env.OTEL_METRICS_EXPORTER) 117: const exportInterval = parseInt( 118: process.env.OTEL_METRIC_EXPORT_INTERVAL || 119: DEFAULT_METRICS_EXPORT_INTERVAL_MS.toString(), 120: ) 121: const exporters = [] 122: for (const exporterType of exporterTypes) { 123: if (exporterType === 'console') { 124: const consoleExporter = new ConsoleMetricExporter() 125: const originalExport = consoleExporter.export.bind(consoleExporter) 126: consoleExporter.export = (metrics, callback) => { 127: if (metrics.resource && metrics.resource.attributes) { 128: logForDebugging('\n=== Resource Attributes ===') 129: logForDebugging(jsonStringify(metrics.resource.attributes)) 130: logForDebugging('===========================\n') 131: } 132: return originalExport(metrics, callback) 133: } 134: exporters.push(consoleExporter) 135: } else if (exporterType === 'otlp') { 136: const protocol = 137: process.env.OTEL_EXPORTER_OTLP_METRICS_PROTOCOL?.trim() || 138: process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim() 139: const httpConfig = getOTLPExporterConfig() 140: switch (protocol) { 141: case 'grpc': { 142: const { OTLPMetricExporter } = await import( 143: '@opentelemetry/exporter-metrics-otlp-grpc' 144: ) 145: exporters.push(new OTLPMetricExporter()) 146: break 147: } 148: case 'http/json': { 149: const { OTLPMetricExporter } = await import( 150: '@opentelemetry/exporter-metrics-otlp-http' 151: ) 152: exporters.push(new OTLPMetricExporter(httpConfig)) 153: break 154: } 155: case 'http/protobuf': { 156: const { OTLPMetricExporter } = await import( 157: '@opentelemetry/exporter-metrics-otlp-proto' 158: ) 159: exporters.push(new OTLPMetricExporter(httpConfig)) 160: break 161: } 162: default: 163: throw new Error( 164: `Unknown protocol set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`, 165: ) 166: } 167: } else if (exporterType === 'prometheus') { 168: const { PrometheusExporter } = await import( 169: '@opentelemetry/exporter-prometheus' 170: ) 171: exporters.push(new PrometheusExporter()) 172: } else { 173: throw new Error( 174: `Unknown exporter type set in OTEL_EXPORTER_OTLP_METRICS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${exporterType}`, 175: ) 176: } 177: } 178: return exporters.map(exporter => { 179: if ('export' in exporter) { 180: return new PeriodicExportingMetricReader({ 181: exporter, 182: exportIntervalMillis: exportInterval, 183: }) 184: } 185: return exporter 186: }) 187: } 188: async function getOtlpLogExporters() { 189: const exporterTypes = parseExporterTypes(process.env.OTEL_LOGS_EXPORTER) 190: const protocol = 191: process.env.OTEL_EXPORTER_OTLP_LOGS_PROTOCOL?.trim() || 192: process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim() 193: const endpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT 194: logForDebugging( 195: `[3P telemetry] getOtlpLogExporters: types=${jsonStringify(exporterTypes)}, protocol=${protocol}, endpoint=${endpoint}`, 196: ) 197: const exporters = [] 198: for (const exporterType of exporterTypes) { 199: if (exporterType === 'console') { 200: exporters.push(new ConsoleLogRecordExporter()) 201: } else if (exporterType === 'otlp') { 202: const httpConfig = getOTLPExporterConfig() 203: switch (protocol) { 204: case 'grpc': { 205: const { OTLPLogExporter } = await import( 206: '@opentelemetry/exporter-logs-otlp-grpc' 207: ) 208: exporters.push(new OTLPLogExporter()) 209: break 210: } 211: case 'http/json': { 212: const { OTLPLogExporter } = await import( 213: '@opentelemetry/exporter-logs-otlp-http' 214: ) 215: exporters.push(new OTLPLogExporter(httpConfig)) 216: break 217: } 218: case 'http/protobuf': { 219: const { OTLPLogExporter } = await import( 220: '@opentelemetry/exporter-logs-otlp-proto' 221: ) 222: exporters.push(new OTLPLogExporter(httpConfig)) 223: break 224: } 225: default: 226: throw new Error( 227: `Unknown protocol set in OTEL_EXPORTER_OTLP_LOGS_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`, 228: ) 229: } 230: } else { 231: throw new Error( 232: `Unknown exporter type set in OTEL_LOGS_EXPORTER env var: ${exporterType}`, 233: ) 234: } 235: } 236: return exporters 237: } 238: async function getOtlpTraceExporters() { 239: const exporterTypes = parseExporterTypes(process.env.OTEL_TRACES_EXPORTER) 240: const exporters = [] 241: for (const exporterType of exporterTypes) { 242: if (exporterType === 'console') { 243: exporters.push(new ConsoleSpanExporter()) 244: } else if (exporterType === 'otlp') { 245: const protocol = 246: process.env.OTEL_EXPORTER_OTLP_TRACES_PROTOCOL?.trim() || 247: process.env.OTEL_EXPORTER_OTLP_PROTOCOL?.trim() 248: const httpConfig = getOTLPExporterConfig() 249: switch (protocol) { 250: case 'grpc': { 251: const { OTLPTraceExporter } = await import( 252: '@opentelemetry/exporter-trace-otlp-grpc' 253: ) 254: exporters.push(new OTLPTraceExporter()) 255: break 256: } 257: case 'http/json': { 258: const { OTLPTraceExporter } = await import( 259: '@opentelemetry/exporter-trace-otlp-http' 260: ) 261: exporters.push(new OTLPTraceExporter(httpConfig)) 262: break 263: } 264: case 'http/protobuf': { 265: const { OTLPTraceExporter } = await import( 266: '@opentelemetry/exporter-trace-otlp-proto' 267: ) 268: exporters.push(new OTLPTraceExporter(httpConfig)) 269: break 270: } 271: default: 272: throw new Error( 273: `Unknown protocol set in OTEL_EXPORTER_OTLP_TRACES_PROTOCOL or OTEL_EXPORTER_OTLP_PROTOCOL env var: ${protocol}`, 274: ) 275: } 276: } else { 277: throw new Error( 278: `Unknown exporter type set in OTEL_TRACES_EXPORTER env var: ${exporterType}`, 279: ) 280: } 281: } 282: return exporters 283: } 284: export function isTelemetryEnabled() { 285: return isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TELEMETRY) 286: } 287: function getBigQueryExportingReader() { 288: const bigqueryExporter = new BigQueryMetricsExporter() 289: return new PeriodicExportingMetricReader({ 290: exporter: bigqueryExporter, 291: exportIntervalMillis: 5 * 60 * 1000, 292: }) 293: } 294: function isBigQueryMetricsEnabled() { 295: const subscriptionType = getSubscriptionType() 296: const isC4EOrTeamUser = 297: isClaudeAISubscriber() && 298: (subscriptionType === 'enterprise' || subscriptionType === 'team') 299: return is1PApiCustomer() || isC4EOrTeamUser 300: } 301: async function initializeBetaTracing( 302: resource: ReturnType<typeof resourceFromAttributes>, 303: ): Promise<void> { 304: const endpoint = process.env.BETA_TRACING_ENDPOINT 305: if (!endpoint) { 306: return 307: } 308: const [{ OTLPTraceExporter }, { OTLPLogExporter }] = await Promise.all([ 309: import('@opentelemetry/exporter-trace-otlp-http'), 310: import('@opentelemetry/exporter-logs-otlp-http'), 311: ]) 312: const httpConfig = { 313: url: `${endpoint}/v1/traces`, 314: } 315: const logHttpConfig = { 316: url: `${endpoint}/v1/logs`, 317: } 318: const traceExporter = new OTLPTraceExporter(httpConfig) 319: const spanProcessor = new BatchSpanProcessor(traceExporter, { 320: scheduledDelayMillis: DEFAULT_TRACES_EXPORT_INTERVAL_MS, 321: }) 322: const tracerProvider = new BasicTracerProvider({ 323: resource, 324: spanProcessors: [spanProcessor], 325: }) 326: trace.setGlobalTracerProvider(tracerProvider) 327: setTracerProvider(tracerProvider) 328: const logExporter = new OTLPLogExporter(logHttpConfig) 329: const loggerProvider = new LoggerProvider({ 330: resource, 331: processors: [ 332: new BatchLogRecordProcessor(logExporter, { 333: scheduledDelayMillis: DEFAULT_LOGS_EXPORT_INTERVAL_MS, 334: }), 335: ], 336: }) 337: logs.setGlobalLoggerProvider(loggerProvider) 338: setLoggerProvider(loggerProvider) 339: const eventLogger = logs.getLogger( 340: 'com.anthropic.claude_code.events', 341: MACRO.VERSION, 342: ) 343: setEventLogger(eventLogger) 344: process.on('beforeExit', async () => { 345: await loggerProvider?.forceFlush() 346: await tracerProvider?.forceFlush() 347: }) 348: process.on('exit', () => { 349: void loggerProvider?.forceFlush() 350: void tracerProvider?.forceFlush() 351: }) 352: } 353: export async function initializeTelemetry() { 354: profileCheckpoint('telemetry_init_start') 355: bootstrapTelemetry() 356: if (getHasFormattedOutput()) { 357: for (const key of [ 358: 'OTEL_METRICS_EXPORTER', 359: 'OTEL_LOGS_EXPORTER', 360: 'OTEL_TRACES_EXPORTER', 361: ] as const) { 362: const v = process.env[key] 363: if (v?.includes('console')) { 364: process.env[key] = v 365: .split(',') 366: .map(s => s.trim()) 367: .filter(s => s !== 'console') 368: .join(',') 369: } 370: } 371: } 372: diag.setLogger(new ClaudeCodeDiagLogger(), DiagLogLevel.ERROR) 373: initializePerfettoTracing() 374: const readers = [] 375: const telemetryEnabled = isTelemetryEnabled() 376: logForDebugging( 377: `[3P telemetry] isTelemetryEnabled=${telemetryEnabled} (CLAUDE_CODE_ENABLE_TELEMETRY=${process.env.CLAUDE_CODE_ENABLE_TELEMETRY})`, 378: ) 379: if (telemetryEnabled) { 380: readers.push(...(await getOtlpReaders())) 381: } 382: if (isBigQueryMetricsEnabled()) { 383: readers.push(getBigQueryExportingReader()) 384: } 385: const platform = getPlatform() 386: const baseAttributes: Record<string, string> = { 387: [ATTR_SERVICE_NAME]: 'claude-code', 388: [ATTR_SERVICE_VERSION]: MACRO.VERSION, 389: } 390: if (platform === 'wsl') { 391: const wslVersion = getWslVersion() 392: if (wslVersion) { 393: baseAttributes['wsl.version'] = wslVersion 394: } 395: } 396: const baseResource = resourceFromAttributes(baseAttributes) 397: const osResource = resourceFromAttributes( 398: osDetector.detect().attributes || {}, 399: ) 400: const hostDetected = hostDetector.detect() 401: const hostArchAttributes = hostDetected.attributes?.[SEMRESATTRS_HOST_ARCH] 402: ? { 403: [SEMRESATTRS_HOST_ARCH]: hostDetected.attributes[SEMRESATTRS_HOST_ARCH], 404: } 405: : {} 406: const hostArchResource = resourceFromAttributes(hostArchAttributes) 407: const envResource = resourceFromAttributes( 408: envDetector.detect().attributes || {}, 409: ) 410: const resource = baseResource 411: .merge(osResource) 412: .merge(hostArchResource) 413: .merge(envResource) 414: if (isBetaTracingEnabled()) { 415: void initializeBetaTracing(resource).catch(e => 416: logForDebugging(`Beta tracing init failed: ${e}`, { level: 'error' }), 417: ) 418: const meterProvider = new MeterProvider({ 419: resource, 420: views: [], 421: readers, 422: }) 423: setMeterProvider(meterProvider) 424: const shutdownTelemetry = async () => { 425: const timeoutMs = parseInt( 426: process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000', 427: ) 428: try { 429: endInteractionSpan() 430: const loggerProvider = getLoggerProvider() 431: const tracerProvider = getTracerProvider() 432: const chains: Promise<void>[] = [meterProvider.shutdown()] 433: if (loggerProvider) { 434: chains.push( 435: loggerProvider.forceFlush().then(() => loggerProvider.shutdown()), 436: ) 437: } 438: if (tracerProvider) { 439: chains.push( 440: tracerProvider.forceFlush().then(() => tracerProvider.shutdown()), 441: ) 442: } 443: await Promise.race([ 444: Promise.all(chains), 445: telemetryTimeout(timeoutMs, 'OpenTelemetry shutdown timeout'), 446: ]) 447: } catch { 448: } 449: } 450: registerCleanup(shutdownTelemetry) 451: return meterProvider.getMeter('com.anthropic.claude_code', MACRO.VERSION) 452: } 453: const meterProvider = new MeterProvider({ 454: resource, 455: views: [], 456: readers, 457: }) 458: setMeterProvider(meterProvider) 459: if (telemetryEnabled) { 460: const logExporters = await getOtlpLogExporters() 461: logForDebugging( 462: `[3P telemetry] Created ${logExporters.length} log exporter(s)`, 463: ) 464: if (logExporters.length > 0) { 465: const loggerProvider = new LoggerProvider({ 466: resource, 467: processors: logExporters.map( 468: exporter => 469: new BatchLogRecordProcessor(exporter, { 470: scheduledDelayMillis: parseInt( 471: process.env.OTEL_LOGS_EXPORT_INTERVAL || 472: DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), 473: ), 474: }), 475: ), 476: }) 477: logs.setGlobalLoggerProvider(loggerProvider) 478: setLoggerProvider(loggerProvider) 479: const eventLogger = logs.getLogger( 480: 'com.anthropic.claude_code.events', 481: MACRO.VERSION, 482: ) 483: setEventLogger(eventLogger) 484: logForDebugging('[3P telemetry] Event logger set successfully') 485: process.on('beforeExit', async () => { 486: await loggerProvider?.forceFlush() 487: const tracerProvider = getTracerProvider() 488: await tracerProvider?.forceFlush() 489: }) 490: process.on('exit', () => { 491: void loggerProvider?.forceFlush() 492: void getTracerProvider()?.forceFlush() 493: }) 494: } 495: } 496: if (telemetryEnabled && isEnhancedTelemetryEnabled()) { 497: const traceExporters = await getOtlpTraceExporters() 498: if (traceExporters.length > 0) { 499: const spanProcessors = traceExporters.map( 500: exporter => 501: new BatchSpanProcessor(exporter, { 502: scheduledDelayMillis: parseInt( 503: process.env.OTEL_TRACES_EXPORT_INTERVAL || 504: DEFAULT_TRACES_EXPORT_INTERVAL_MS.toString(), 505: ), 506: }), 507: ) 508: const tracerProvider = new BasicTracerProvider({ 509: resource, 510: spanProcessors, 511: }) 512: trace.setGlobalTracerProvider(tracerProvider) 513: setTracerProvider(tracerProvider) 514: } 515: } 516: const shutdownTelemetry = async () => { 517: const timeoutMs = parseInt( 518: process.env.CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS || '2000', 519: ) 520: try { 521: endInteractionSpan() 522: const shutdownPromises = [meterProvider.shutdown()] 523: const loggerProvider = getLoggerProvider() 524: if (loggerProvider) { 525: shutdownPromises.push(loggerProvider.shutdown()) 526: } 527: const tracerProvider = getTracerProvider() 528: if (tracerProvider) { 529: shutdownPromises.push(tracerProvider.shutdown()) 530: } 531: await Promise.race([ 532: Promise.all(shutdownPromises), 533: telemetryTimeout(timeoutMs, 'OpenTelemetry shutdown timeout'), 534: ]) 535: } catch (error) { 536: if (error instanceof Error && error.message.includes('timeout')) { 537: logForDebugging( 538: ` 539: OpenTelemetry telemetry flush timed out after ${timeoutMs}ms 540: To resolve this issue, you can: 541: 1. Increase the timeout by setting CLAUDE_CODE_OTEL_SHUTDOWN_TIMEOUT_MS env var (e.g., 5000 for 5 seconds) 542: 2. Check if your OpenTelemetry backend is experiencing scalability issues 543: 3. Disable OpenTelemetry by unsetting CLAUDE_CODE_ENABLE_TELEMETRY env var 544: Current timeout: ${timeoutMs}ms 545: `, 546: { level: 'error' }, 547: ) 548: } 549: throw error 550: } 551: } 552: registerCleanup(shutdownTelemetry) 553: return meterProvider.getMeter('com.anthropic.claude_code', MACRO.VERSION) 554: } 555: export async function flushTelemetry(): Promise<void> { 556: const meterProvider = getMeterProvider() 557: if (!meterProvider) { 558: return 559: } 560: const timeoutMs = parseInt( 561: process.env.CLAUDE_CODE_OTEL_FLUSH_TIMEOUT_MS || '5000', 562: ) 563: try { 564: const flushPromises = [meterProvider.forceFlush()] 565: const loggerProvider = getLoggerProvider() 566: if (loggerProvider) { 567: flushPromises.push(loggerProvider.forceFlush()) 568: } 569: const tracerProvider = getTracerProvider() 570: if (tracerProvider) { 571: flushPromises.push(tracerProvider.forceFlush()) 572: } 573: await Promise.race([ 574: Promise.all(flushPromises), 575: telemetryTimeout(timeoutMs, 'OpenTelemetry flush timeout'), 576: ]) 577: logForDebugging('Telemetry flushed successfully') 578: } catch (error) { 579: if (error instanceof TelemetryTimeoutError) { 580: logForDebugging( 581: `Telemetry flush timed out after ${timeoutMs}ms. Some metrics may not be exported.`, 582: { level: 'warn' }, 583: ) 584: } else { 585: logForDebugging(`Telemetry flush failed: ${errorMessage(error)}`, { 586: level: 'error', 587: }) 588: } 589: } 590: } 591: function parseOtelHeadersEnvVar(): Record<string, string> { 592: const headers: Record<string, string> = {} 593: const envHeaders = process.env.OTEL_EXPORTER_OTLP_HEADERS 594: if (envHeaders) { 595: for (const pair of envHeaders.split(',')) { 596: const [key, ...valueParts] = pair.split('=') 597: if (key && valueParts.length > 0) { 598: headers[key.trim()] = valueParts.join('=').trim() 599: } 600: } 601: } 602: return headers 603: } 604: function getOTLPExporterConfig() { 605: const proxyUrl = getProxyUrl() 606: const mtlsConfig = getMTLSConfig() 607: const settings = getSettings_DEPRECATED() 608: const config: Record<string, unknown> = {} 609: const staticHeaders = parseOtelHeadersEnvVar() 610: if (settings?.otelHeadersHelper) { 611: config.headers = async (): Promise<Record<string, string>> => { 612: const dynamicHeaders = getOtelHeadersFromHelper() 613: return { ...staticHeaders, ...dynamicHeaders } 614: } 615: } else if (Object.keys(staticHeaders).length > 0) { 616: config.headers = async (): Promise<Record<string, string>> => staticHeaders 617: } 618: const otelEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT 619: if (!proxyUrl || (otelEndpoint && shouldBypassProxy(otelEndpoint))) { 620: const caCerts = getCACertificates() 621: if (mtlsConfig || caCerts) { 622: config.httpAgentOptions = { 623: ...mtlsConfig, 624: ...(caCerts && { ca: caCerts }), 625: } 626: } 627: return config 628: } 629: const caCerts = getCACertificates() 630: const agentFactory = (_protocol: string) => { 631: const proxyAgent = 632: mtlsConfig || caCerts 633: ? new HttpsProxyAgent(proxyUrl, { 634: ...(mtlsConfig && { 635: cert: mtlsConfig.cert, 636: key: mtlsConfig.key, 637: passphrase: mtlsConfig.passphrase, 638: }), 639: ...(caCerts && { ca: caCerts }), 640: }) 641: : new HttpsProxyAgent(proxyUrl) 642: return proxyAgent 643: } 644: config.httpAgentOptions = agentFactory 645: return config 646: }

File: src/utils/telemetry/logger.ts

typescript 1: import type { DiagLogger } from '@opentelemetry/api' 2: import { logForDebugging } from '../debug.js' 3: import { logError } from '../log.js' 4: export class ClaudeCodeDiagLogger implements DiagLogger { 5: error(message: string, ..._: unknown[]) { 6: logError(new Error(message)) 7: logForDebugging(`[3P telemetry] OTEL diag error: ${message}`, { 8: level: 'error', 9: }) 10: } 11: warn(message: string, ..._: unknown[]) { 12: logError(new Error(message)) 13: logForDebugging(`[3P telemetry] OTEL diag warn: ${message}`, { 14: level: 'warn', 15: }) 16: } 17: info(_message: string, ..._args: unknown[]) { 18: return 19: } 20: debug(_message: string, ..._args: unknown[]) { 21: return 22: } 23: verbose(_message: string, ..._args: unknown[]) { 24: return 25: } 26: }

File: src/utils/telemetry/perfettoTracing.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { mkdirSync, writeFileSync } from 'fs' 3: import { mkdir, writeFile } from 'fs/promises' 4: import { dirname, join } from 'path' 5: import { getSessionId } from '../../bootstrap/state.js' 6: import { registerCleanup } from '../cleanupRegistry.js' 7: import { logForDebugging } from '../debug.js' 8: import { 9: getClaudeConfigHomeDir, 10: isEnvDefinedFalsy, 11: isEnvTruthy, 12: } from '../envUtils.js' 13: import { errorMessage } from '../errors.js' 14: import { djb2Hash } from '../hash.js' 15: import { jsonStringify } from '../slowOperations.js' 16: import { getAgentId, getAgentName, getParentSessionId } from '../teammate.js' 17: export type TraceEventPhase = 18: | 'B' 19: | 'E' 20: | 'X' 21: | 'i' 22: | 'C' 23: | 'b' 24: | 'n' 25: | 'e' 26: | 'M' 27: export type TraceEvent = { 28: name: string 29: cat: string 30: ph: TraceEventPhase 31: ts: number 32: pid: number 33: tid: number 34: dur?: number 35: args?: Record<string, unknown> 36: id?: string 37: scope?: string 38: } 39: type AgentInfo = { 40: agentId: string 41: agentName: string 42: parentAgentId?: string 43: processId: number 44: threadId: number 45: } 46: type PendingSpan = { 47: name: string 48: category: string 49: startTime: number 50: agentInfo: AgentInfo 51: args: Record<string, unknown> 52: } 53: let isEnabled = false 54: let tracePath: string | null = null 55: const metadataEvents: TraceEvent[] = [] 56: const events: TraceEvent[] = [] 57: const MAX_EVENTS = 100_000 58: const pendingSpans = new Map<string, PendingSpan>() 59: const agentRegistry = new Map<string, AgentInfo>() 60: let totalAgentCount = 0 61: let startTimeMs = 0 62: let spanIdCounter = 0 63: let traceWritten = false 64: let processIdCounter = 1 65: const agentIdToProcessId = new Map<string, number>() 66: let writeIntervalId: ReturnType<typeof setInterval> | null = null 67: const STALE_SPAN_TTL_MS = 30 * 60 * 1000 68: const STALE_SPAN_CLEANUP_INTERVAL_MS = 60 * 1000 69: let staleSpanCleanupId: ReturnType<typeof setInterval> | null = null 70: function stringToNumericHash(str: string): number { 71: return Math.abs(djb2Hash(str)) || 1 72: } 73: function getProcessIdForAgent(agentId: string): number { 74: const existing = agentIdToProcessId.get(agentId) 75: if (existing !== undefined) return existing 76: processIdCounter++ 77: agentIdToProcessId.set(agentId, processIdCounter) 78: return processIdCounter 79: } 80: function getCurrentAgentInfo(): AgentInfo { 81: const agentId = getAgentId() ?? getSessionId() 82: const agentName = getAgentName() ?? 'main' 83: const parentSessionId = getParentSessionId() 84: const existing = agentRegistry.get(agentId) 85: if (existing) return existing 86: const info: AgentInfo = { 87: agentId, 88: agentName, 89: parentAgentId: parentSessionId, 90: processId: agentId === getSessionId() ? 1 : getProcessIdForAgent(agentId), 91: threadId: stringToNumericHash(agentName), 92: } 93: agentRegistry.set(agentId, info) 94: totalAgentCount++ 95: return info 96: } 97: function getTimestamp(): number { 98: return (Date.now() - startTimeMs) * 1000 99: } 100: function generateSpanId(): string { 101: return `span_${++spanIdCounter}` 102: } 103: function evictStaleSpans(): void { 104: const now = getTimestamp() 105: const ttlUs = STALE_SPAN_TTL_MS * 1000 106: for (const [spanId, span] of pendingSpans) { 107: if (now - span.startTime > ttlUs) { 108: events.push({ 109: name: span.name, 110: cat: span.category, 111: ph: 'E', 112: ts: now, 113: pid: span.agentInfo.processId, 114: tid: span.agentInfo.threadId, 115: args: { 116: ...span.args, 117: evicted: true, 118: duration_ms: (now - span.startTime) / 1000, 119: }, 120: }) 121: pendingSpans.delete(spanId) 122: } 123: } 124: } 125: function buildTraceDocument(): string { 126: return jsonStringify({ 127: traceEvents: [...metadataEvents, ...events], 128: metadata: { 129: session_id: getSessionId(), 130: trace_start_time: new Date(startTimeMs).toISOString(), 131: agent_count: totalAgentCount, 132: total_event_count: metadataEvents.length + events.length, 133: }, 134: }) 135: } 136: function evictOldestEvents(): void { 137: if (events.length < MAX_EVENTS) return 138: const dropped = events.splice(0, MAX_EVENTS / 2) 139: events.unshift({ 140: name: 'trace_truncated', 141: cat: '__metadata', 142: ph: 'i', 143: ts: dropped[dropped.length - 1]?.ts ?? 0, 144: pid: 1, 145: tid: 0, 146: args: { dropped_events: dropped.length }, 147: }) 148: logForDebugging( 149: `[Perfetto] Evicted ${dropped.length} oldest events (cap ${MAX_EVENTS})`, 150: ) 151: } 152: export function initializePerfettoTracing(): void { 153: const envValue = process.env.CLAUDE_CODE_PERFETTO_TRACE 154: logForDebugging( 155: `[Perfetto] initializePerfettoTracing called, env value: ${envValue}`, 156: ) 157: if (feature('PERFETTO_TRACING')) { 158: if (!envValue || isEnvDefinedFalsy(envValue)) { 159: logForDebugging( 160: '[Perfetto] Tracing disabled (env var not set or disabled)', 161: ) 162: return 163: } 164: isEnabled = true 165: startTimeMs = Date.now() 166: if (isEnvTruthy(envValue)) { 167: const tracesDir = join(getClaudeConfigHomeDir(), 'traces') 168: tracePath = join(tracesDir, `trace-${getSessionId()}.json`) 169: } else { 170: tracePath = envValue 171: } 172: logForDebugging( 173: `[Perfetto] Tracing enabled, will write to: ${tracePath}, isEnabled=${isEnabled}`, 174: ) 175: const intervalSec = parseInt( 176: process.env.CLAUDE_CODE_PERFETTO_WRITE_INTERVAL_S ?? '', 177: 10, 178: ) 179: if (intervalSec > 0) { 180: writeIntervalId = setInterval(() => { 181: void periodicWrite() 182: }, intervalSec * 1000) 183: // Don't let the interval keep the process alive on its own 184: if (writeIntervalId.unref) writeIntervalId.unref() 185: logForDebugging( 186: `[Perfetto] Periodic write enabled, interval: ${intervalSec}s`, 187: ) 188: } 189: staleSpanCleanupId = setInterval(() => { 190: evictStaleSpans() 191: evictOldestEvents() 192: }, STALE_SPAN_CLEANUP_INTERVAL_MS) 193: if (staleSpanCleanupId.unref) staleSpanCleanupId.unref() 194: registerCleanup(async () => { 195: logForDebugging('[Perfetto] Cleanup callback invoked') 196: await writePerfettoTrace() 197: }) 198: process.on('beforeExit', () => { 199: logForDebugging('[Perfetto] beforeExit handler invoked') 200: void writePerfettoTrace() 201: }) 202: process.on('exit', () => { 203: if (!traceWritten) { 204: logForDebugging( 205: '[Perfetto] exit handler invoked, writing trace synchronously', 206: ) 207: writePerfettoTraceSync() 208: } 209: }) 210: const mainAgent = getCurrentAgentInfo() 211: emitProcessMetadata(mainAgent) 212: } 213: } 214: function emitProcessMetadata(agentInfo: AgentInfo): void { 215: if (!isEnabled) return 216: metadataEvents.push({ 217: name: 'process_name', 218: cat: '__metadata', 219: ph: 'M', 220: ts: 0, 221: pid: agentInfo.processId, 222: tid: 0, 223: args: { name: agentInfo.agentName }, 224: }) 225: metadataEvents.push({ 226: name: 'thread_name', 227: cat: '__metadata', 228: ph: 'M', 229: ts: 0, 230: pid: agentInfo.processId, 231: tid: agentInfo.threadId, 232: args: { name: agentInfo.agentName }, 233: }) 234: if (agentInfo.parentAgentId) { 235: metadataEvents.push({ 236: name: 'parent_agent', 237: cat: '__metadata', 238: ph: 'M', 239: ts: 0, 240: pid: agentInfo.processId, 241: tid: 0, 242: args: { 243: parent_agent_id: agentInfo.parentAgentId, 244: }, 245: }) 246: } 247: } 248: export function isPerfettoTracingEnabled(): boolean { 249: return isEnabled 250: } 251: export function registerAgent( 252: agentId: string, 253: agentName: string, 254: parentAgentId?: string, 255: ): void { 256: if (!isEnabled) return 257: const info: AgentInfo = { 258: agentId, 259: agentName, 260: parentAgentId, 261: processId: getProcessIdForAgent(agentId), 262: threadId: stringToNumericHash(agentName), 263: } 264: agentRegistry.set(agentId, info) 265: totalAgentCount++ 266: emitProcessMetadata(info) 267: } 268: export function unregisterAgent(agentId: string): void { 269: if (!isEnabled) return 270: agentRegistry.delete(agentId) 271: agentIdToProcessId.delete(agentId) 272: } 273: export function startLLMRequestPerfettoSpan(args: { 274: model: string 275: promptTokens?: number 276: messageId?: string 277: isSpeculative?: boolean 278: querySource?: string 279: }): string { 280: if (!isEnabled) return '' 281: const spanId = generateSpanId() 282: const agentInfo = getCurrentAgentInfo() 283: pendingSpans.set(spanId, { 284: name: 'API Call', 285: category: 'api', 286: startTime: getTimestamp(), 287: agentInfo, 288: args: { 289: model: args.model, 290: prompt_tokens: args.promptTokens, 291: message_id: args.messageId, 292: is_speculative: args.isSpeculative ?? false, 293: query_source: args.querySource, 294: }, 295: }) 296: events.push({ 297: name: 'API Call', 298: cat: 'api', 299: ph: 'B', 300: ts: pendingSpans.get(spanId)!.startTime, 301: pid: agentInfo.processId, 302: tid: agentInfo.threadId, 303: args: pendingSpans.get(spanId)!.args, 304: }) 305: return spanId 306: } 307: export function endLLMRequestPerfettoSpan( 308: spanId: string, 309: metadata: { 310: ttftMs?: number 311: ttltMs?: number 312: promptTokens?: number 313: outputTokens?: number 314: cacheReadTokens?: number 315: cacheCreationTokens?: number 316: messageId?: string 317: success?: boolean 318: error?: string 319: requestSetupMs?: number 320: attemptStartTimes?: number[] 321: }, 322: ): void { 323: if (!isEnabled || !spanId) return 324: const pending = pendingSpans.get(spanId) 325: if (!pending) return 326: const endTime = getTimestamp() 327: const duration = endTime - pending.startTime 328: const promptTokens = 329: metadata.promptTokens ?? (pending.args.prompt_tokens as number | undefined) 330: const ttftMs = metadata.ttftMs 331: const ttltMs = metadata.ttltMs 332: const outputTokens = metadata.outputTokens 333: const cacheReadTokens = metadata.cacheReadTokens 334: const itps = 335: ttftMs !== undefined && promptTokens !== undefined && ttftMs > 0 336: ? Math.round((promptTokens / (ttftMs / 1000)) * 100) / 100 337: : undefined 338: const samplingMs = 339: ttltMs !== undefined && ttftMs !== undefined ? ttltMs - ttftMs : undefined 340: const otps = 341: samplingMs !== undefined && outputTokens !== undefined && samplingMs > 0 342: ? Math.round((outputTokens / (samplingMs / 1000)) * 100) / 100 343: : undefined 344: const cacheHitRate = 345: cacheReadTokens !== undefined && 346: promptTokens !== undefined && 347: promptTokens > 0 348: ? Math.round((cacheReadTokens / promptTokens) * 10000) / 100 349: : undefined 350: const requestSetupMs = metadata.requestSetupMs 351: const attemptStartTimes = metadata.attemptStartTimes 352: const args = { 353: ...pending.args, 354: ttft_ms: ttftMs, 355: ttlt_ms: ttltMs, 356: prompt_tokens: promptTokens, 357: output_tokens: outputTokens, 358: cache_read_tokens: cacheReadTokens, 359: cache_creation_tokens: metadata.cacheCreationTokens, 360: message_id: metadata.messageId ?? pending.args.message_id, 361: success: metadata.success ?? true, 362: error: metadata.error, 363: duration_ms: duration / 1000, 364: request_setup_ms: requestSetupMs, 365: itps, 366: otps, 367: cache_hit_rate_pct: cacheHitRate, 368: } 369: const setupUs = 370: requestSetupMs !== undefined && requestSetupMs > 0 371: ? requestSetupMs * 1000 372: : 0 373: if (setupUs > 0) { 374: const setupEndTs = pending.startTime + setupUs 375: events.push({ 376: name: 'Request Setup', 377: cat: 'api,setup', 378: ph: 'B', 379: ts: pending.startTime, 380: pid: pending.agentInfo.processId, 381: tid: pending.agentInfo.threadId, 382: args: { 383: request_setup_ms: requestSetupMs, 384: attempt_count: attemptStartTimes?.length ?? 1, 385: }, 386: }) 387: if (attemptStartTimes && attemptStartTimes.length > 1) { 388: const baseWallMs = attemptStartTimes[0]! 389: for (let i = 0; i < attemptStartTimes.length - 1; i++) { 390: const attemptStartUs = 391: pending.startTime + (attemptStartTimes[i]! - baseWallMs) * 1000 392: const attemptEndUs = 393: pending.startTime + (attemptStartTimes[i + 1]! - baseWallMs) * 1000 394: events.push({ 395: name: `Attempt ${i + 1} (retry)`, 396: cat: 'api,retry', 397: ph: 'B', 398: ts: attemptStartUs, 399: pid: pending.agentInfo.processId, 400: tid: pending.agentInfo.threadId, 401: args: { attempt: i + 1 }, 402: }) 403: events.push({ 404: name: `Attempt ${i + 1} (retry)`, 405: cat: 'api,retry', 406: ph: 'E', 407: ts: attemptEndUs, 408: pid: pending.agentInfo.processId, 409: tid: pending.agentInfo.threadId, 410: }) 411: } 412: } 413: events.push({ 414: name: 'Request Setup', 415: cat: 'api,setup', 416: ph: 'E', 417: ts: setupEndTs, 418: pid: pending.agentInfo.processId, 419: tid: pending.agentInfo.threadId, 420: }) 421: } 422: if (ttftMs !== undefined) { 423: const firstTokenStartTs = pending.startTime + setupUs 424: const firstTokenEndTs = firstTokenStartTs + ttftMs * 1000 425: events.push({ 426: name: 'First Token', 427: cat: 'api,ttft', 428: ph: 'B', 429: ts: firstTokenStartTs, 430: pid: pending.agentInfo.processId, 431: tid: pending.agentInfo.threadId, 432: args: { 433: ttft_ms: ttftMs, 434: prompt_tokens: promptTokens, 435: itps, 436: cache_hit_rate_pct: cacheHitRate, 437: }, 438: }) 439: events.push({ 440: name: 'First Token', 441: cat: 'api,ttft', 442: ph: 'E', 443: ts: firstTokenEndTs, 444: pid: pending.agentInfo.processId, 445: tid: pending.agentInfo.threadId, 446: }) 447: const actualSamplingMs = 448: ttltMs !== undefined ? ttltMs - ttftMs - setupUs / 1000 : undefined 449: if (actualSamplingMs !== undefined && actualSamplingMs > 0) { 450: events.push({ 451: name: 'Sampling', 452: cat: 'api,sampling', 453: ph: 'B', 454: ts: firstTokenEndTs, 455: pid: pending.agentInfo.processId, 456: tid: pending.agentInfo.threadId, 457: args: { 458: sampling_ms: actualSamplingMs, 459: output_tokens: outputTokens, 460: otps, 461: }, 462: }) 463: events.push({ 464: name: 'Sampling', 465: cat: 'api,sampling', 466: ph: 'E', 467: ts: firstTokenEndTs + actualSamplingMs * 1000, 468: pid: pending.agentInfo.processId, 469: tid: pending.agentInfo.threadId, 470: }) 471: } 472: } 473: events.push({ 474: name: pending.name, 475: cat: pending.category, 476: ph: 'E', 477: ts: endTime, 478: pid: pending.agentInfo.processId, 479: tid: pending.agentInfo.threadId, 480: args, 481: }) 482: pendingSpans.delete(spanId) 483: } 484: export function startToolPerfettoSpan( 485: toolName: string, 486: args?: Record<string, unknown>, 487: ): string { 488: if (!isEnabled) return '' 489: const spanId = generateSpanId() 490: const agentInfo = getCurrentAgentInfo() 491: pendingSpans.set(spanId, { 492: name: `Tool: ${toolName}`, 493: category: 'tool', 494: startTime: getTimestamp(), 495: agentInfo, 496: args: { 497: tool_name: toolName, 498: ...args, 499: }, 500: }) 501: events.push({ 502: name: `Tool: ${toolName}`, 503: cat: 'tool', 504: ph: 'B', 505: ts: pendingSpans.get(spanId)!.startTime, 506: pid: agentInfo.processId, 507: tid: agentInfo.threadId, 508: args: pendingSpans.get(spanId)!.args, 509: }) 510: return spanId 511: } 512: export function endToolPerfettoSpan( 513: spanId: string, 514: metadata?: { 515: success?: boolean 516: error?: string 517: resultTokens?: number 518: }, 519: ): void { 520: if (!isEnabled || !spanId) return 521: const pending = pendingSpans.get(spanId) 522: if (!pending) return 523: const endTime = getTimestamp() 524: const duration = endTime - pending.startTime 525: const args = { 526: ...pending.args, 527: success: metadata?.success ?? true, 528: error: metadata?.error, 529: result_tokens: metadata?.resultTokens, 530: duration_ms: duration / 1000, 531: } 532: events.push({ 533: name: pending.name, 534: cat: pending.category, 535: ph: 'E', 536: ts: endTime, 537: pid: pending.agentInfo.processId, 538: tid: pending.agentInfo.threadId, 539: args, 540: }) 541: pendingSpans.delete(spanId) 542: } 543: export function startUserInputPerfettoSpan(context?: string): string { 544: if (!isEnabled) return '' 545: const spanId = generateSpanId() 546: const agentInfo = getCurrentAgentInfo() 547: pendingSpans.set(spanId, { 548: name: 'Waiting for User Input', 549: category: 'user_input', 550: startTime: getTimestamp(), 551: agentInfo, 552: args: { 553: context, 554: }, 555: }) 556: events.push({ 557: name: 'Waiting for User Input', 558: cat: 'user_input', 559: ph: 'B', 560: ts: pendingSpans.get(spanId)!.startTime, 561: pid: agentInfo.processId, 562: tid: agentInfo.threadId, 563: args: pendingSpans.get(spanId)!.args, 564: }) 565: return spanId 566: } 567: export function endUserInputPerfettoSpan( 568: spanId: string, 569: metadata?: { 570: decision?: string 571: source?: string 572: }, 573: ): void { 574: if (!isEnabled || !spanId) return 575: const pending = pendingSpans.get(spanId) 576: if (!pending) return 577: const endTime = getTimestamp() 578: const duration = endTime - pending.startTime 579: const args = { 580: ...pending.args, 581: decision: metadata?.decision, 582: source: metadata?.source, 583: duration_ms: duration / 1000, 584: } 585: events.push({ 586: name: pending.name, 587: cat: pending.category, 588: ph: 'E', 589: ts: endTime, 590: pid: pending.agentInfo.processId, 591: tid: pending.agentInfo.threadId, 592: args, 593: }) 594: pendingSpans.delete(spanId) 595: } 596: export function emitPerfettoInstant( 597: name: string, 598: category: string, 599: args?: Record<string, unknown>, 600: ): void { 601: if (!isEnabled) return 602: const agentInfo = getCurrentAgentInfo() 603: events.push({ 604: name, 605: cat: category, 606: ph: 'i', 607: ts: getTimestamp(), 608: pid: agentInfo.processId, 609: tid: agentInfo.threadId, 610: args, 611: }) 612: } 613: export function emitPerfettoCounter( 614: name: string, 615: values: Record<string, number>, 616: ): void { 617: if (!isEnabled) return 618: const agentInfo = getCurrentAgentInfo() 619: events.push({ 620: name, 621: cat: 'counter', 622: ph: 'C', 623: ts: getTimestamp(), 624: pid: agentInfo.processId, 625: tid: agentInfo.threadId, 626: args: values, 627: }) 628: } 629: export function startInteractionPerfettoSpan(userPrompt?: string): string { 630: if (!isEnabled) return '' 631: const spanId = generateSpanId() 632: const agentInfo = getCurrentAgentInfo() 633: pendingSpans.set(spanId, { 634: name: 'Interaction', 635: category: 'interaction', 636: startTime: getTimestamp(), 637: agentInfo, 638: args: { 639: user_prompt_length: userPrompt?.length, 640: }, 641: }) 642: events.push({ 643: name: 'Interaction', 644: cat: 'interaction', 645: ph: 'B', 646: ts: pendingSpans.get(spanId)!.startTime, 647: pid: agentInfo.processId, 648: tid: agentInfo.threadId, 649: args: pendingSpans.get(spanId)!.args, 650: }) 651: return spanId 652: } 653: export function endInteractionPerfettoSpan(spanId: string): void { 654: if (!isEnabled || !spanId) return 655: const pending = pendingSpans.get(spanId) 656: if (!pending) return 657: const endTime = getTimestamp() 658: const duration = endTime - pending.startTime 659: events.push({ 660: name: pending.name, 661: cat: pending.category, 662: ph: 'E', 663: ts: endTime, 664: pid: pending.agentInfo.processId, 665: tid: pending.agentInfo.threadId, 666: args: { 667: ...pending.args, 668: duration_ms: duration / 1000, 669: }, 670: }) 671: pendingSpans.delete(spanId) 672: } 673: function stopWriteInterval(): void { 674: if (staleSpanCleanupId) { 675: clearInterval(staleSpanCleanupId) 676: staleSpanCleanupId = null 677: } 678: if (writeIntervalId) { 679: clearInterval(writeIntervalId) 680: writeIntervalId = null 681: } 682: } 683: function closeOpenSpans(): void { 684: for (const [spanId, pending] of pendingSpans) { 685: const endTime = getTimestamp() 686: events.push({ 687: name: pending.name, 688: cat: pending.category, 689: ph: 'E', 690: ts: endTime, 691: pid: pending.agentInfo.processId, 692: tid: pending.agentInfo.threadId, 693: args: { 694: ...pending.args, 695: incomplete: true, 696: duration_ms: (endTime - pending.startTime) / 1000, 697: }, 698: }) 699: pendingSpans.delete(spanId) 700: } 701: } 702: async function periodicWrite(): Promise<void> { 703: if (!isEnabled || !tracePath || traceWritten) return 704: try { 705: await mkdir(dirname(tracePath), { recursive: true }) 706: await writeFile(tracePath, buildTraceDocument()) 707: logForDebugging( 708: `[Perfetto] Periodic write: ${events.length} events to ${tracePath}`, 709: ) 710: } catch (error) { 711: logForDebugging( 712: `[Perfetto] Periodic write failed: ${errorMessage(error)}`, 713: { level: 'error' }, 714: ) 715: } 716: } 717: async function writePerfettoTrace(): Promise<void> { 718: if (!isEnabled || !tracePath || traceWritten) { 719: logForDebugging( 720: `[Perfetto] Skipping final write: isEnabled=${isEnabled}, tracePath=${tracePath}, traceWritten=${traceWritten}`, 721: ) 722: return 723: } 724: stopWriteInterval() 725: closeOpenSpans() 726: logForDebugging( 727: `[Perfetto] writePerfettoTrace called: events=${events.length}`, 728: ) 729: try { 730: await mkdir(dirname(tracePath), { recursive: true }) 731: await writeFile(tracePath, buildTraceDocument()) 732: traceWritten = true 733: logForDebugging(`[Perfetto] Trace finalized at: ${tracePath}`) 734: } catch (error) { 735: logForDebugging( 736: `[Perfetto] Failed to write final trace: ${errorMessage(error)}`, 737: { level: 'error' }, 738: ) 739: } 740: } 741: function writePerfettoTraceSync(): void { 742: if (!isEnabled || !tracePath || traceWritten) { 743: logForDebugging( 744: `[Perfetto] Skipping final sync write: isEnabled=${isEnabled}, tracePath=${tracePath}, traceWritten=${traceWritten}`, 745: ) 746: return 747: } 748: stopWriteInterval() 749: closeOpenSpans() 750: logForDebugging( 751: `[Perfetto] writePerfettoTraceSync called: events=${events.length}`, 752: ) 753: try { 754: const dir = dirname(tracePath) 755: mkdirSync(dir, { recursive: true }) 756: writeFileSync(tracePath, buildTraceDocument()) 757: traceWritten = true 758: logForDebugging(`[Perfetto] Trace finalized synchronously at: ${tracePath}`) 759: } catch (error) { 760: logForDebugging( 761: `[Perfetto] Failed to write final trace synchronously: ${errorMessage(error)}`, 762: { level: 'error' }, 763: ) 764: } 765: } 766: export function getPerfettoEvents(): TraceEvent[] { 767: return [...metadataEvents, ...events] 768: } 769: export function resetPerfettoTracer(): void { 770: if (staleSpanCleanupId) { 771: clearInterval(staleSpanCleanupId) 772: staleSpanCleanupId = null 773: } 774: stopWriteInterval() 775: metadataEvents.length = 0 776: events.length = 0 777: pendingSpans.clear() 778: agentRegistry.clear() 779: agentIdToProcessId.clear() 780: totalAgentCount = 0 781: processIdCounter = 1 782: spanIdCounter = 0 783: isEnabled = false 784: tracePath = null 785: startTimeMs = 0 786: traceWritten = false 787: } 788: export async function triggerPeriodicWriteForTesting(): Promise<void> { 789: await periodicWrite() 790: } 791: export function evictStaleSpansForTesting(): void { 792: evictStaleSpans() 793: } 794: export const MAX_EVENTS_FOR_TESTING = MAX_EVENTS 795: export function evictOldestEventsForTesting(): void { 796: evictOldestEvents() 797: }

File: src/utils/telemetry/pluginTelemetry.ts

typescript 1: import { createHash } from 'crypto' 2: import { sep } from 'path' 3: import { 4: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 5: type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 6: logEvent, 7: } from '../../services/analytics/index.js' 8: import type { 9: LoadedPlugin, 10: PluginError, 11: PluginManifest, 12: } from '../../types/plugin.js' 13: import { 14: isOfficialMarketplaceName, 15: parsePluginIdentifier, 16: } from '../plugins/pluginIdentifier.js' 17: const BUILTIN_MARKETPLACE_NAME = 'builtin' 18: const PLUGIN_ID_HASH_SALT = 'claude-plugin-telemetry-v1' 19: export function hashPluginId(name: string, marketplace?: string): string { 20: const key = marketplace ? `${name}@${marketplace.toLowerCase()}` : name 21: return createHash('sha256') 22: .update(key + PLUGIN_ID_HASH_SALT) 23: .digest('hex') 24: .slice(0, 16) 25: } 26: export type TelemetryPluginScope = 27: | 'official' 28: | 'org' 29: | 'user-local' 30: | 'default-bundle' 31: export function getTelemetryPluginScope( 32: name: string, 33: marketplace: string | undefined, 34: managedNames: Set<string> | null, 35: ): TelemetryPluginScope { 36: if (marketplace === BUILTIN_MARKETPLACE_NAME) return 'default-bundle' 37: if (isOfficialMarketplaceName(marketplace)) return 'official' 38: if (managedNames?.has(name)) return 'org' 39: return 'user-local' 40: } 41: export type EnabledVia = 42: | 'user-install' 43: | 'org-policy' 44: | 'default-enable' 45: | 'seed-mount' 46: export type InvocationTrigger = 47: | 'user-slash' 48: | 'claude-proactive' 49: | 'nested-skill' 50: export type SkillExecutionContext = 'fork' | 'inline' | 'remote' 51: export type InstallSource = 52: | 'cli-explicit' 53: | 'ui-discover' 54: | 'ui-suggestion' 55: | 'deep-link' 56: export function getEnabledVia( 57: plugin: LoadedPlugin, 58: managedNames: Set<string> | null, 59: seedDirs: string[], 60: ): EnabledVia { 61: if (plugin.isBuiltin) return 'default-enable' 62: if (managedNames?.has(plugin.name)) return 'org-policy' 63: if ( 64: seedDirs.some(dir => 65: plugin.path.startsWith(dir.endsWith(sep) ? dir : dir + sep), 66: ) 67: ) { 68: return 'seed-mount' 69: } 70: return 'user-install' 71: } 72: export function buildPluginTelemetryFields( 73: name: string, 74: marketplace: string | undefined, 75: managedNames: Set<string> | null = null, 76: ): { 77: plugin_id_hash: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 78: plugin_scope: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 79: plugin_name_redacted: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 80: marketplace_name_redacted: AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 81: is_official_plugin: boolean 82: } { 83: const scope = getTelemetryPluginScope(name, marketplace, managedNames) 84: const isAnthropicControlled = 85: scope === 'official' || scope === 'default-bundle' 86: return { 87: plugin_id_hash: hashPluginId( 88: name, 89: marketplace, 90: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 91: plugin_scope: 92: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 93: plugin_name_redacted: (isAnthropicControlled 94: ? name 95: : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 96: marketplace_name_redacted: (isAnthropicControlled && marketplace 97: ? marketplace 98: : 'third-party') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 99: is_official_plugin: isAnthropicControlled, 100: } 101: } 102: export function buildPluginCommandTelemetryFields( 103: pluginInfo: { pluginManifest: PluginManifest; repository: string }, 104: managedNames: Set<string> | null = null, 105: ): ReturnType<typeof buildPluginTelemetryFields> { 106: const { marketplace } = parsePluginIdentifier(pluginInfo.repository) 107: return buildPluginTelemetryFields( 108: pluginInfo.pluginManifest.name, 109: marketplace, 110: managedNames, 111: ) 112: } 113: export function logPluginsEnabledForSession( 114: plugins: LoadedPlugin[], 115: managedNames: Set<string> | null, 116: seedDirs: string[], 117: ): void { 118: for (const plugin of plugins) { 119: const { marketplace } = parsePluginIdentifier(plugin.repository) 120: logEvent('tengu_plugin_enabled_for_session', { 121: _PROTO_plugin_name: 122: plugin.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 123: ...(marketplace && { 124: _PROTO_marketplace_name: 125: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 126: }), 127: ...buildPluginTelemetryFields(plugin.name, marketplace, managedNames), 128: enabled_via: getEnabledVia( 129: plugin, 130: managedNames, 131: seedDirs, 132: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 133: skill_path_count: 134: (plugin.skillsPath ? 1 : 0) + (plugin.skillsPaths?.length ?? 0), 135: command_path_count: 136: (plugin.commandsPath ? 1 : 0) + (plugin.commandsPaths?.length ?? 0), 137: has_mcp: plugin.manifest.mcpServers !== undefined, 138: has_hooks: plugin.hooksConfig !== undefined, 139: ...(plugin.manifest.version && { 140: version: plugin.manifest 141: .version as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 142: }), 143: }) 144: } 145: } 146: export type PluginCommandErrorCategory = 147: | 'network' 148: | 'not-found' 149: | 'permission' 150: | 'validation' 151: | 'unknown' 152: export function classifyPluginCommandError( 153: error: unknown, 154: ): PluginCommandErrorCategory { 155: const msg = String((error as { message?: unknown })?.message ?? error) 156: if ( 157: /ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|network|Could not resolve|Connection refused|timed out/i.test( 158: msg, 159: ) 160: ) { 161: return 'network' 162: } 163: if (/\b404\b|not found|does not exist|no such plugin/i.test(msg)) { 164: return 'not-found' 165: } 166: if (/\b40[13]\b|EACCES|EPERM|permission denied|unauthorized/i.test(msg)) { 167: return 'permission' 168: } 169: if (/invalid|malformed|schema|validation|parse error/i.test(msg)) { 170: return 'validation' 171: } 172: return 'unknown' 173: } 174: export function logPluginLoadErrors( 175: errors: PluginError[], 176: managedNames: Set<string> | null, 177: ): void { 178: for (const err of errors) { 179: const { name, marketplace } = parsePluginIdentifier(err.source) 180: const pluginName = 'plugin' in err && err.plugin ? err.plugin : name 181: logEvent('tengu_plugin_load_failed', { 182: error_category: 183: err.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 184: _PROTO_plugin_name: 185: pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 186: ...(marketplace && { 187: _PROTO_marketplace_name: 188: marketplace as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 189: }), 190: ...buildPluginTelemetryFields(pluginName, marketplace, managedNames), 191: }) 192: } 193: }

File: src/utils/telemetry/sessionTracing.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { context as otelContext, type Span, trace } from '@opentelemetry/api' 3: import { AsyncLocalStorage } from 'async_hooks' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 5: import type { AssistantMessage, UserMessage } from '../../types/message.js' 6: import { isEnvDefinedFalsy, isEnvTruthy } from '../envUtils.js' 7: import { getTelemetryAttributes } from '../telemetryAttributes.js' 8: import { 9: addBetaInteractionAttributes, 10: addBetaLLMRequestAttributes, 11: addBetaLLMResponseAttributes, 12: addBetaToolInputAttributes, 13: addBetaToolResultAttributes, 14: isBetaTracingEnabled, 15: type LLMRequestNewContext, 16: truncateContent, 17: } from './betaSessionTracing.js' 18: import { 19: endInteractionPerfettoSpan, 20: endLLMRequestPerfettoSpan, 21: endToolPerfettoSpan, 22: endUserInputPerfettoSpan, 23: isPerfettoTracingEnabled, 24: startInteractionPerfettoSpan, 25: startLLMRequestPerfettoSpan, 26: startToolPerfettoSpan, 27: startUserInputPerfettoSpan, 28: } from './perfettoTracing.js' 29: export type { Span } 30: export { isBetaTracingEnabled, type LLMRequestNewContext } 31: type APIMessage = UserMessage | AssistantMessage 32: type SpanType = 33: | 'interaction' 34: | 'llm_request' 35: | 'tool' 36: | 'tool.blocked_on_user' 37: | 'tool.execution' 38: | 'hook' 39: interface SpanContext { 40: span: Span 41: startTime: number 42: attributes: Record<string, string | number | boolean> 43: ended?: boolean 44: perfettoSpanId?: string 45: } 46: const interactionContext = new AsyncLocalStorage<SpanContext | undefined>() 47: const toolContext = new AsyncLocalStorage<SpanContext | undefined>() 48: const activeSpans = new Map<string, WeakRef<SpanContext>>() 49: const strongSpans = new Map<string, SpanContext>() 50: let interactionSequence = 0 51: let _cleanupIntervalStarted = false 52: const SPAN_TTL_MS = 30 * 60 * 1000 53: function getSpanId(span: Span): string { 54: return span.spanContext().spanId || '' 55: } 56: /** 57: * Lazily start a background interval that evicts orphaned spans from activeSpans. 58: * 59: * Normal teardown calls endInteractionSpan / endToolSpan, which delete spans 60: * immediately. This interval is a safety net for spans that were never ended 61: * (e.g. aborted streams, uncaught exceptions mid-query) — without it they 62: * accumulate in activeSpans indefinitely, holding references to Span objects 63: * and the OpenTelemetry context chain. 64: * 65: * Initialized on the first startInteractionSpan call (not at module load) to 66: * avoid triggering the no-top-level-side-effects lint rule and to keep the 67: * interval from running in processes that never start a span. 68: * unref() prevents the timer from keeping the process alive after all other 69: * work is done. 70: */ 71: function ensureCleanupInterval(): void { 72: if (_cleanupIntervalStarted) return 73: _cleanupIntervalStarted = true 74: const interval = setInterval(() => { 75: const cutoff = Date.now() - SPAN_TTL_MS 76: for (const [spanId, weakRef] of activeSpans) { 77: const ctx = weakRef.deref() 78: if (ctx === undefined) { 79: activeSpans.delete(spanId) 80: strongSpans.delete(spanId) 81: } else if (ctx.startTime < cutoff) { 82: if (!ctx.ended) ctx.span.end() // flush any recorded attributes to the exporter 83: activeSpans.delete(spanId) 84: strongSpans.delete(spanId) 85: } 86: } 87: }, 60_000) 88: if (typeof interval.unref === 'function') { 89: interval.unref() 90: } 91: } 92: export function isEnhancedTelemetryEnabled(): boolean { 93: if (feature('ENHANCED_TELEMETRY_BETA')) { 94: const env = 95: process.env.CLAUDE_CODE_ENHANCED_TELEMETRY_BETA ?? 96: process.env.ENABLE_ENHANCED_TELEMETRY_BETA 97: if (isEnvTruthy(env)) { 98: return true 99: } 100: if (isEnvDefinedFalsy(env)) { 101: return false 102: } 103: return ( 104: process.env.USER_TYPE === 'ant' || 105: getFeatureValue_CACHED_MAY_BE_STALE('enhanced_telemetry_beta', false) 106: ) 107: } 108: return false 109: } 110: function isAnyTracingEnabled(): boolean { 111: return isEnhancedTelemetryEnabled() || isBetaTracingEnabled() 112: } 113: function getTracer() { 114: return trace.getTracer('com.anthropic.claude_code.tracing', '1.0.0') 115: } 116: function createSpanAttributes( 117: spanType: SpanType, 118: customAttributes: Record<string, string | number | boolean> = {}, 119: ): Record<string, string | number | boolean> { 120: const baseAttributes = getTelemetryAttributes() 121: const attributes: Record<string, string | number | boolean> = { 122: ...baseAttributes, 123: 'span.type': spanType, 124: ...customAttributes, 125: } 126: return attributes 127: } 128: export function startInteractionSpan(userPrompt: string): Span { 129: ensureCleanupInterval() 130: const perfettoSpanId = isPerfettoTracingEnabled() 131: ? startInteractionPerfettoSpan(userPrompt) 132: : undefined 133: if (!isAnyTracingEnabled()) { 134: if (perfettoSpanId) { 135: const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 136: const spanId = getSpanId(dummySpan) 137: const spanContextObj: SpanContext = { 138: span: dummySpan, 139: startTime: Date.now(), 140: attributes: {}, 141: perfettoSpanId, 142: } 143: activeSpans.set(spanId, new WeakRef(spanContextObj)) 144: interactionContext.enterWith(spanContextObj) 145: return dummySpan 146: } 147: return trace.getActiveSpan() || getTracer().startSpan('dummy') 148: } 149: const tracer = getTracer() 150: const isUserPromptLoggingEnabled = isEnvTruthy( 151: process.env.OTEL_LOG_USER_PROMPTS, 152: ) 153: const promptToLog = isUserPromptLoggingEnabled ? userPrompt : '<REDACTED>' 154: interactionSequence++ 155: const attributes = createSpanAttributes('interaction', { 156: user_prompt: promptToLog, 157: user_prompt_length: userPrompt.length, 158: 'interaction.sequence': interactionSequence, 159: }) 160: const span = tracer.startSpan('claude_code.interaction', { 161: attributes, 162: }) 163: addBetaInteractionAttributes(span, userPrompt) 164: const spanId = getSpanId(span) 165: const spanContextObj: SpanContext = { 166: span, 167: startTime: Date.now(), 168: attributes, 169: perfettoSpanId, 170: } 171: activeSpans.set(spanId, new WeakRef(spanContextObj)) 172: interactionContext.enterWith(spanContextObj) 173: return span 174: } 175: export function endInteractionSpan(): void { 176: const spanContext = interactionContext.getStore() 177: if (!spanContext) { 178: return 179: } 180: if (spanContext.ended) { 181: return 182: } 183: if (spanContext.perfettoSpanId) { 184: endInteractionPerfettoSpan(spanContext.perfettoSpanId) 185: } 186: if (!isAnyTracingEnabled()) { 187: spanContext.ended = true 188: activeSpans.delete(getSpanId(spanContext.span)) 189: interactionContext.enterWith(undefined) 190: return 191: } 192: const duration = Date.now() - spanContext.startTime 193: spanContext.span.setAttributes({ 194: 'interaction.duration_ms': duration, 195: }) 196: spanContext.span.end() 197: spanContext.ended = true 198: activeSpans.delete(getSpanId(spanContext.span)) 199: interactionContext.enterWith(undefined) 200: } 201: export function startLLMRequestSpan( 202: model: string, 203: newContext?: LLMRequestNewContext, 204: messagesForAPI?: APIMessage[], 205: fastMode?: boolean, 206: ): Span { 207: const perfettoSpanId = isPerfettoTracingEnabled() 208: ? startLLMRequestPerfettoSpan({ 209: model, 210: querySource: newContext?.querySource, 211: messageId: undefined, 212: }) 213: : undefined 214: if (!isAnyTracingEnabled()) { 215: if (perfettoSpanId) { 216: const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 217: const spanId = getSpanId(dummySpan) 218: const spanContextObj: SpanContext = { 219: span: dummySpan, 220: startTime: Date.now(), 221: attributes: { model }, 222: perfettoSpanId, 223: } 224: activeSpans.set(spanId, new WeakRef(spanContextObj)) 225: strongSpans.set(spanId, spanContextObj) 226: return dummySpan 227: } 228: return trace.getActiveSpan() || getTracer().startSpan('dummy') 229: } 230: const tracer = getTracer() 231: const parentSpanCtx = interactionContext.getStore() 232: const attributes = createSpanAttributes('llm_request', { 233: model: model, 234: 'llm_request.context': parentSpanCtx ? 'interaction' : 'standalone', 235: speed: fastMode ? 'fast' : 'normal', 236: }) 237: const ctx = parentSpanCtx 238: ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 239: : otelContext.active() 240: const span = tracer.startSpan('claude_code.llm_request', { attributes }, ctx) 241: if (newContext?.querySource) { 242: span.setAttribute('query_source', newContext.querySource) 243: } 244: addBetaLLMRequestAttributes(span, newContext, messagesForAPI) 245: const spanId = getSpanId(span) 246: const spanContextObj: SpanContext = { 247: span, 248: startTime: Date.now(), 249: attributes, 250: perfettoSpanId, 251: } 252: activeSpans.set(spanId, new WeakRef(spanContextObj)) 253: strongSpans.set(spanId, spanContextObj) 254: return span 255: } 256: export function endLLMRequestSpan( 257: span?: Span, 258: metadata?: { 259: inputTokens?: number 260: outputTokens?: number 261: cacheReadTokens?: number 262: cacheCreationTokens?: number 263: success?: boolean 264: statusCode?: number 265: error?: string 266: attempt?: number 267: modelResponse?: string 268: modelOutput?: string 269: thinkingOutput?: string 270: hasToolCall?: boolean 271: ttftMs?: number 272: requestSetupMs?: number 273: attemptStartTimes?: number[] 274: }, 275: ): void { 276: let llmSpanContext: SpanContext | undefined 277: if (span) { 278: const spanId = getSpanId(span) 279: llmSpanContext = activeSpans.get(spanId)?.deref() 280: } else { 281: llmSpanContext = Array.from(activeSpans.values()) 282: .findLast(r => { 283: const ctx = r.deref() 284: return ( 285: ctx?.attributes['span.type'] === 'llm_request' || 286: ctx?.attributes['model'] 287: ) 288: }) 289: ?.deref() 290: } 291: if (!llmSpanContext) { 292: return 293: } 294: const duration = Date.now() - llmSpanContext.startTime 295: if (llmSpanContext.perfettoSpanId) { 296: endLLMRequestPerfettoSpan(llmSpanContext.perfettoSpanId, { 297: ttftMs: metadata?.ttftMs, 298: ttltMs: duration, 299: promptTokens: metadata?.inputTokens, 300: outputTokens: metadata?.outputTokens, 301: cacheReadTokens: metadata?.cacheReadTokens, 302: cacheCreationTokens: metadata?.cacheCreationTokens, 303: success: metadata?.success, 304: error: metadata?.error, 305: requestSetupMs: metadata?.requestSetupMs, 306: attemptStartTimes: metadata?.attemptStartTimes, 307: }) 308: } 309: if (!isAnyTracingEnabled()) { 310: const spanId = getSpanId(llmSpanContext.span) 311: activeSpans.delete(spanId) 312: strongSpans.delete(spanId) 313: return 314: } 315: const endAttributes: Record<string, string | number | boolean> = { 316: duration_ms: duration, 317: } 318: if (metadata) { 319: if (metadata.inputTokens !== undefined) 320: endAttributes['input_tokens'] = metadata.inputTokens 321: if (metadata.outputTokens !== undefined) 322: endAttributes['output_tokens'] = metadata.outputTokens 323: if (metadata.cacheReadTokens !== undefined) 324: endAttributes['cache_read_tokens'] = metadata.cacheReadTokens 325: if (metadata.cacheCreationTokens !== undefined) 326: endAttributes['cache_creation_tokens'] = metadata.cacheCreationTokens 327: if (metadata.success !== undefined) 328: endAttributes['success'] = metadata.success 329: if (metadata.statusCode !== undefined) 330: endAttributes['status_code'] = metadata.statusCode 331: if (metadata.error !== undefined) endAttributes['error'] = metadata.error 332: if (metadata.attempt !== undefined) 333: endAttributes['attempt'] = metadata.attempt 334: if (metadata.hasToolCall !== undefined) 335: endAttributes['response.has_tool_call'] = metadata.hasToolCall 336: if (metadata.ttftMs !== undefined) 337: endAttributes['ttft_ms'] = metadata.ttftMs 338: addBetaLLMResponseAttributes(endAttributes, metadata) 339: } 340: llmSpanContext.span.setAttributes(endAttributes) 341: llmSpanContext.span.end() 342: const spanId = getSpanId(llmSpanContext.span) 343: activeSpans.delete(spanId) 344: strongSpans.delete(spanId) 345: } 346: export function startToolSpan( 347: toolName: string, 348: toolAttributes?: Record<string, string | number | boolean>, 349: toolInput?: string, 350: ): Span { 351: const perfettoSpanId = isPerfettoTracingEnabled() 352: ? startToolPerfettoSpan(toolName, toolAttributes) 353: : undefined 354: if (!isAnyTracingEnabled()) { 355: if (perfettoSpanId) { 356: const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 357: const spanId = getSpanId(dummySpan) 358: const spanContextObj: SpanContext = { 359: span: dummySpan, 360: startTime: Date.now(), 361: attributes: { 'span.type': 'tool', tool_name: toolName }, 362: perfettoSpanId, 363: } 364: activeSpans.set(spanId, new WeakRef(spanContextObj)) 365: toolContext.enterWith(spanContextObj) 366: return dummySpan 367: } 368: return trace.getActiveSpan() || getTracer().startSpan('dummy') 369: } 370: const tracer = getTracer() 371: const parentSpanCtx = interactionContext.getStore() 372: const attributes = createSpanAttributes('tool', { 373: tool_name: toolName, 374: ...toolAttributes, 375: }) 376: const ctx = parentSpanCtx 377: ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 378: : otelContext.active() 379: const span = tracer.startSpan('claude_code.tool', { attributes }, ctx) 380: if (toolInput) { 381: addBetaToolInputAttributes(span, toolName, toolInput) 382: } 383: const spanId = getSpanId(span) 384: const spanContextObj: SpanContext = { 385: span, 386: startTime: Date.now(), 387: attributes, 388: perfettoSpanId, 389: } 390: activeSpans.set(spanId, new WeakRef(spanContextObj)) 391: toolContext.enterWith(spanContextObj) 392: return span 393: } 394: export function startToolBlockedOnUserSpan(): Span { 395: const perfettoSpanId = isPerfettoTracingEnabled() 396: ? startUserInputPerfettoSpan('tool_permission') 397: : undefined 398: if (!isAnyTracingEnabled()) { 399: if (perfettoSpanId) { 400: const dummySpan = trace.getActiveSpan() || getTracer().startSpan('dummy') 401: const spanId = getSpanId(dummySpan) 402: const spanContextObj: SpanContext = { 403: span: dummySpan, 404: startTime: Date.now(), 405: attributes: { 'span.type': 'tool.blocked_on_user' }, 406: perfettoSpanId, 407: } 408: activeSpans.set(spanId, new WeakRef(spanContextObj)) 409: strongSpans.set(spanId, spanContextObj) 410: return dummySpan 411: } 412: return trace.getActiveSpan() || getTracer().startSpan('dummy') 413: } 414: const tracer = getTracer() 415: const parentSpanCtx = toolContext.getStore() 416: const attributes = createSpanAttributes('tool.blocked_on_user') 417: const ctx = parentSpanCtx 418: ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 419: : otelContext.active() 420: const span = tracer.startSpan( 421: 'claude_code.tool.blocked_on_user', 422: { attributes }, 423: ctx, 424: ) 425: const spanId = getSpanId(span) 426: const spanContextObj: SpanContext = { 427: span, 428: startTime: Date.now(), 429: attributes, 430: perfettoSpanId, 431: } 432: activeSpans.set(spanId, new WeakRef(spanContextObj)) 433: strongSpans.set(spanId, spanContextObj) 434: return span 435: } 436: export function endToolBlockedOnUserSpan( 437: decision?: string, 438: source?: string, 439: ): void { 440: const blockedSpanContext = Array.from(activeSpans.values()) 441: .findLast( 442: r => r.deref()?.attributes['span.type'] === 'tool.blocked_on_user', 443: ) 444: ?.deref() 445: if (!blockedSpanContext) { 446: return 447: } 448: if (blockedSpanContext.perfettoSpanId) { 449: endUserInputPerfettoSpan(blockedSpanContext.perfettoSpanId, { 450: decision, 451: source, 452: }) 453: } 454: if (!isAnyTracingEnabled()) { 455: const spanId = getSpanId(blockedSpanContext.span) 456: activeSpans.delete(spanId) 457: strongSpans.delete(spanId) 458: return 459: } 460: const duration = Date.now() - blockedSpanContext.startTime 461: const attributes: Record<string, string | number | boolean> = { 462: duration_ms: duration, 463: } 464: if (decision) { 465: attributes['decision'] = decision 466: } 467: if (source) { 468: attributes['source'] = source 469: } 470: blockedSpanContext.span.setAttributes(attributes) 471: blockedSpanContext.span.end() 472: const spanId = getSpanId(blockedSpanContext.span) 473: activeSpans.delete(spanId) 474: strongSpans.delete(spanId) 475: } 476: export function startToolExecutionSpan(): Span { 477: if (!isAnyTracingEnabled()) { 478: return trace.getActiveSpan() || getTracer().startSpan('dummy') 479: } 480: const tracer = getTracer() 481: const parentSpanCtx = toolContext.getStore() 482: const attributes = createSpanAttributes('tool.execution') 483: const ctx = parentSpanCtx 484: ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 485: : otelContext.active() 486: const span = tracer.startSpan( 487: 'claude_code.tool.execution', 488: { attributes }, 489: ctx, 490: ) 491: const spanId = getSpanId(span) 492: const spanContextObj: SpanContext = { 493: span, 494: startTime: Date.now(), 495: attributes, 496: } 497: activeSpans.set(spanId, new WeakRef(spanContextObj)) 498: strongSpans.set(spanId, spanContextObj) 499: return span 500: } 501: export function endToolExecutionSpan(metadata?: { 502: success?: boolean 503: error?: string 504: }): void { 505: if (!isAnyTracingEnabled()) { 506: return 507: } 508: const executionSpanContext = Array.from(activeSpans.values()) 509: .findLast(r => r.deref()?.attributes['span.type'] === 'tool.execution') 510: ?.deref() 511: if (!executionSpanContext) { 512: return 513: } 514: const duration = Date.now() - executionSpanContext.startTime 515: const attributes: Record<string, string | number | boolean> = { 516: duration_ms: duration, 517: } 518: if (metadata) { 519: if (metadata.success !== undefined) attributes['success'] = metadata.success 520: if (metadata.error !== undefined) attributes['error'] = metadata.error 521: } 522: executionSpanContext.span.setAttributes(attributes) 523: executionSpanContext.span.end() 524: const spanId = getSpanId(executionSpanContext.span) 525: activeSpans.delete(spanId) 526: strongSpans.delete(spanId) 527: } 528: export function endToolSpan(toolResult?: string, resultTokens?: number): void { 529: const toolSpanContext = toolContext.getStore() 530: if (!toolSpanContext) { 531: return 532: } 533: if (toolSpanContext.perfettoSpanId) { 534: endToolPerfettoSpan(toolSpanContext.perfettoSpanId, { 535: success: true, 536: resultTokens, 537: }) 538: } 539: if (!isAnyTracingEnabled()) { 540: const spanId = getSpanId(toolSpanContext.span) 541: activeSpans.delete(spanId) 542: toolContext.enterWith(undefined) 543: return 544: } 545: const duration = Date.now() - toolSpanContext.startTime 546: const endAttributes: Record<string, string | number | boolean> = { 547: duration_ms: duration, 548: } 549: if (toolResult) { 550: const toolName = toolSpanContext.attributes['tool_name'] || 'unknown' 551: addBetaToolResultAttributes(endAttributes, toolName, toolResult) 552: } 553: if (resultTokens !== undefined) { 554: endAttributes['result_tokens'] = resultTokens 555: } 556: toolSpanContext.span.setAttributes(endAttributes) 557: toolSpanContext.span.end() 558: const spanId = getSpanId(toolSpanContext.span) 559: activeSpans.delete(spanId) 560: toolContext.enterWith(undefined) 561: } 562: function isToolContentLoggingEnabled(): boolean { 563: return isEnvTruthy(process.env.OTEL_LOG_TOOL_CONTENT) 564: } 565: export function addToolContentEvent( 566: eventName: string, 567: attributes: Record<string, string | number | boolean>, 568: ): void { 569: if (!isAnyTracingEnabled() || !isToolContentLoggingEnabled()) { 570: return 571: } 572: const currentSpanCtx = toolContext.getStore() 573: if (!currentSpanCtx) { 574: return 575: } 576: const processedAttributes: Record<string, string | number | boolean> = {} 577: for (const [key, value] of Object.entries(attributes)) { 578: if (typeof value === 'string') { 579: const { content, truncated } = truncateContent(value) 580: processedAttributes[key] = content 581: if (truncated) { 582: processedAttributes[`${key}_truncated`] = true 583: processedAttributes[`${key}_original_length`] = value.length 584: } 585: } else { 586: processedAttributes[key] = value 587: } 588: } 589: currentSpanCtx.span.addEvent(eventName, processedAttributes) 590: } 591: export function getCurrentSpan(): Span | null { 592: if (!isAnyTracingEnabled()) { 593: return null 594: } 595: return ( 596: toolContext.getStore()?.span ?? interactionContext.getStore()?.span ?? null 597: ) 598: } 599: export async function executeInSpan<T>( 600: spanName: string, 601: fn: (span: Span) => Promise<T>, 602: attributes?: Record<string, string | number | boolean>, 603: ): Promise<T> { 604: if (!isAnyTracingEnabled()) { 605: return fn(trace.getActiveSpan() || getTracer().startSpan('dummy')) 606: } 607: const tracer = getTracer() 608: const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore() 609: const finalAttributes = createSpanAttributes('tool', { 610: ...attributes, 611: }) 612: const ctx = parentSpanCtx 613: ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 614: : otelContext.active() 615: const span = tracer.startSpan(spanName, { attributes: finalAttributes }, ctx) 616: const spanId = getSpanId(span) 617: const spanContextObj: SpanContext = { 618: span, 619: startTime: Date.now(), 620: attributes: finalAttributes, 621: } 622: activeSpans.set(spanId, new WeakRef(spanContextObj)) 623: strongSpans.set(spanId, spanContextObj) 624: try { 625: const result = await fn(span) 626: span.end() 627: activeSpans.delete(spanId) 628: strongSpans.delete(spanId) 629: return result 630: } catch (error) { 631: if (error instanceof Error) { 632: span.recordException(error) 633: } 634: span.end() 635: activeSpans.delete(spanId) 636: strongSpans.delete(spanId) 637: throw error 638: } 639: } 640: export function startHookSpan( 641: hookEvent: string, 642: hookName: string, 643: numHooks: number, 644: hookDefinitions: string, 645: ): Span { 646: if (!isBetaTracingEnabled()) { 647: return trace.getActiveSpan() || getTracer().startSpan('dummy') 648: } 649: const tracer = getTracer() 650: const parentSpanCtx = toolContext.getStore() ?? interactionContext.getStore() 651: const attributes = createSpanAttributes('hook', { 652: hook_event: hookEvent, 653: hook_name: hookName, 654: num_hooks: numHooks, 655: hook_definitions: hookDefinitions, 656: }) 657: const ctx = parentSpanCtx 658: ? trace.setSpan(otelContext.active(), parentSpanCtx.span) 659: : otelContext.active() 660: const span = tracer.startSpan('claude_code.hook', { attributes }, ctx) 661: const spanId = getSpanId(span) 662: const spanContextObj: SpanContext = { 663: span, 664: startTime: Date.now(), 665: attributes, 666: } 667: activeSpans.set(spanId, new WeakRef(spanContextObj)) 668: strongSpans.set(spanId, spanContextObj) 669: return span 670: } 671: export function endHookSpan( 672: span: Span, 673: metadata?: { 674: numSuccess?: number 675: numBlocking?: number 676: numNonBlockingError?: number 677: numCancelled?: number 678: }, 679: ): void { 680: if (!isBetaTracingEnabled()) { 681: return 682: } 683: const spanId = getSpanId(span) 684: const spanContext = activeSpans.get(spanId)?.deref() 685: if (!spanContext) { 686: return 687: } 688: const duration = Date.now() - spanContext.startTime 689: const endAttributes: Record<string, string | number | boolean> = { 690: duration_ms: duration, 691: } 692: if (metadata) { 693: if (metadata.numSuccess !== undefined) 694: endAttributes['num_success'] = metadata.numSuccess 695: if (metadata.numBlocking !== undefined) 696: endAttributes['num_blocking'] = metadata.numBlocking 697: if (metadata.numNonBlockingError !== undefined) 698: endAttributes['num_non_blocking_error'] = metadata.numNonBlockingError 699: if (metadata.numCancelled !== undefined) 700: endAttributes['num_cancelled'] = metadata.numCancelled 701: } 702: spanContext.span.setAttributes(endAttributes) 703: spanContext.span.end() 704: activeSpans.delete(spanId) 705: strongSpans.delete(spanId) 706: }

File: src/utils/telemetry/skillLoadedEvent.ts

typescript 1: import { getSkillToolCommands } from '../../commands.js' 2: import { 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 5: logEvent, 6: } from '../../services/analytics/index.js' 7: import { getCharBudget } from '../../tools/SkillTool/prompt.js' 8: export async function logSkillsLoaded( 9: cwd: string, 10: contextWindowTokens: number, 11: ): Promise<void> { 12: const skills = await getSkillToolCommands(cwd) 13: const skillBudget = getCharBudget(contextWindowTokens) 14: for (const skill of skills) { 15: if (skill.type !== 'prompt') continue 16: logEvent('tengu_skill_loaded', { 17: _PROTO_skill_name: 18: skill.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, 19: skill_source: 20: skill.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 21: skill_loaded_from: 22: skill.loadedFrom as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 23: skill_budget: skillBudget, 24: ...(skill.kind && { 25: skill_kind: 26: skill.kind as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 27: }), 28: }) 29: } 30: }

File: src/utils/teleport/api.ts

typescript 1: import axios, { type AxiosRequestConfig, type AxiosResponse } from 'axios' 2: import { randomUUID } from 'crypto' 3: import { getOauthConfig } from 'src/constants/oauth.js' 4: import { getOrganizationUUID } from 'src/services/oauth/client.js' 5: import z from 'zod/v4' 6: import { getClaudeAIOAuthTokens } from '../auth.js' 7: import { logForDebugging } from '../debug.js' 8: import { parseGitHubRepository } from '../detectRepository.js' 9: import { errorMessage, toError } from '../errors.js' 10: import { lazySchema } from '../lazySchema.js' 11: import { logError } from '../log.js' 12: import { sleep } from '../sleep.js' 13: import { jsonStringify } from '../slowOperations.js' 14: const TELEPORT_RETRY_DELAYS = [2000, 4000, 8000, 16000] 15: const MAX_TELEPORT_RETRIES = TELEPORT_RETRY_DELAYS.length 16: export const CCR_BYOC_BETA = 'ccr-byoc-2025-07-29' 17: export function isTransientNetworkError(error: unknown): boolean { 18: if (!axios.isAxiosError(error)) { 19: return false 20: } 21: if (!error.response) { 22: return true 23: } 24: if (error.response.status >= 500) { 25: return true 26: } 27: return false 28: } 29: export async function axiosGetWithRetry<T>( 30: url: string, 31: config?: AxiosRequestConfig, 32: ): Promise<AxiosResponse<T>> { 33: let lastError: unknown 34: for (let attempt = 0; attempt <= MAX_TELEPORT_RETRIES; attempt++) { 35: try { 36: return await axios.get<T>(url, config) 37: } catch (error) { 38: lastError = error 39: if (!isTransientNetworkError(error)) { 40: throw error 41: } 42: if (attempt >= MAX_TELEPORT_RETRIES) { 43: logForDebugging( 44: `Teleport request failed after ${attempt + 1} attempts: ${errorMessage(error)}`, 45: ) 46: throw error 47: } 48: const delay = TELEPORT_RETRY_DELAYS[attempt] ?? 2000 49: logForDebugging( 50: `Teleport request failed (attempt ${attempt + 1}/${MAX_TELEPORT_RETRIES + 1}), retrying in ${delay}ms: ${errorMessage(error)}`, 51: ) 52: await sleep(delay) 53: } 54: } 55: throw lastError 56: } 57: export type SessionStatus = 'requires_action' | 'running' | 'idle' | 'archived' 58: export type GitSource = { 59: type: 'git_repository' 60: url: string 61: revision?: string | null 62: allow_unrestricted_git_push?: boolean 63: } 64: export type KnowledgeBaseSource = { 65: type: 'knowledge_base' 66: knowledge_base_id: string 67: } 68: export type SessionContextSource = GitSource | KnowledgeBaseSource 69: export type OutcomeGitInfo = { 70: type: 'github' 71: repo: string 72: branches: string[] 73: } 74: export type GitRepositoryOutcome = { 75: type: 'git_repository' 76: git_info: OutcomeGitInfo 77: } 78: export type Outcome = GitRepositoryOutcome 79: export type SessionContext = { 80: sources: SessionContextSource[] 81: cwd: string 82: outcomes: Outcome[] | null 83: custom_system_prompt: string | null 84: append_system_prompt: string | null 85: model: string | null 86: seed_bundle_file_id?: string 87: github_pr?: { owner: string; repo: string; number: number } 88: reuse_outcome_branches?: boolean 89: } 90: export type SessionResource = { 91: type: 'session' 92: id: string 93: title: string | null 94: session_status: SessionStatus 95: environment_id: string 96: created_at: string 97: updated_at: string 98: session_context: SessionContext 99: } 100: export type ListSessionsResponse = { 101: data: SessionResource[] 102: has_more: boolean 103: first_id: string | null 104: last_id: string | null 105: } 106: export const CodeSessionSchema = lazySchema(() => 107: z.object({ 108: id: z.string(), 109: title: z.string(), 110: description: z.string(), 111: status: z.enum([ 112: 'idle', 113: 'working', 114: 'waiting', 115: 'completed', 116: 'archived', 117: 'cancelled', 118: 'rejected', 119: ]), 120: repo: z 121: .object({ 122: name: z.string(), 123: owner: z.object({ 124: login: z.string(), 125: }), 126: default_branch: z.string().optional(), 127: }) 128: .nullable(), 129: turns: z.array(z.string()), 130: created_at: z.string(), 131: updated_at: z.string(), 132: }), 133: ) 134: export type CodeSession = z.infer<ReturnType<typeof CodeSessionSchema>> 135: export async function prepareApiRequest(): Promise<{ 136: accessToken: string 137: orgUUID: string 138: }> { 139: const accessToken = getClaudeAIOAuthTokens()?.accessToken 140: if (accessToken === undefined) { 141: throw new Error( 142: 'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.', 143: ) 144: } 145: const orgUUID = await getOrganizationUUID() 146: if (!orgUUID) { 147: throw new Error('Unable to get organization UUID') 148: } 149: return { accessToken, orgUUID } 150: } 151: export async function fetchCodeSessionsFromSessionsAPI(): Promise< 152: CodeSession[] 153: > { 154: const { accessToken, orgUUID } = await prepareApiRequest() 155: const url = `${getOauthConfig().BASE_API_URL}/v1/sessions` 156: try { 157: const headers = { 158: ...getOAuthHeaders(accessToken), 159: 'anthropic-beta': 'ccr-byoc-2025-07-29', 160: 'x-organization-uuid': orgUUID, 161: } 162: const response = await axiosGetWithRetry<ListSessionsResponse>(url, { 163: headers, 164: }) 165: if (response.status !== 200) { 166: throw new Error(`Failed to fetch code sessions: ${response.statusText}`) 167: } 168: const sessions: CodeSession[] = response.data.data.map(session => { 169: const gitSource = session.session_context.sources.find( 170: (source): source is GitSource => source.type === 'git_repository', 171: ) 172: let repo: CodeSession['repo'] = null 173: if (gitSource?.url) { 174: const repoPath = parseGitHubRepository(gitSource.url) 175: if (repoPath) { 176: const [owner, name] = repoPath.split('/') 177: if (owner && name) { 178: repo = { 179: name, 180: owner: { 181: login: owner, 182: }, 183: default_branch: gitSource.revision || undefined, 184: } 185: } 186: } 187: } 188: return { 189: id: session.id, 190: title: session.title || 'Untitled', 191: description: '', // SessionResource doesn't have description field 192: status: session.session_status as CodeSession['status'], 193: repo, 194: turns: [], 195: created_at: session.created_at, 196: updated_at: session.updated_at, 197: } 198: }) 199: return sessions 200: } catch (error) { 201: const err = toError(error) 202: logError(err) 203: throw error 204: } 205: } 206: export function getOAuthHeaders(accessToken: string): Record<string, string> { 207: return { 208: Authorization: `Bearer ${accessToken}`, 209: 'Content-Type': 'application/json', 210: 'anthropic-version': '2023-06-01', 211: } 212: } 213: export async function fetchSession( 214: sessionId: string, 215: ): Promise<SessionResource> { 216: const { accessToken, orgUUID } = await prepareApiRequest() 217: const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` 218: const headers = { 219: ...getOAuthHeaders(accessToken), 220: 'anthropic-beta': 'ccr-byoc-2025-07-29', 221: 'x-organization-uuid': orgUUID, 222: } 223: const response = await axios.get<SessionResource>(url, { 224: headers, 225: timeout: 15000, 226: validateStatus: status => status < 500, 227: }) 228: if (response.status !== 200) { 229: const errorData = response.data as { error?: { message?: string } } 230: const apiMessage = errorData?.error?.message 231: if (response.status === 404) { 232: throw new Error(`Session not found: ${sessionId}`) 233: } 234: if (response.status === 401) { 235: throw new Error('Session expired. Please run /login to sign in again.') 236: } 237: throw new Error( 238: apiMessage || 239: `Failed to fetch session: ${response.status} ${response.statusText}`, 240: ) 241: } 242: return response.data 243: } 244: export function getBranchFromSession( 245: session: SessionResource, 246: ): string | undefined { 247: const gitOutcome = session.session_context.outcomes?.find( 248: (outcome): outcome is GitRepositoryOutcome => 249: outcome.type === 'git_repository', 250: ) 251: return gitOutcome?.git_info?.branches[0] 252: } 253: export type RemoteMessageContent = 254: | string 255: | Array<{ type: string; [key: string]: unknown }> 256: export async function sendEventToRemoteSession( 257: sessionId: string, 258: messageContent: RemoteMessageContent, 259: opts?: { uuid?: string }, 260: ): Promise<boolean> { 261: try { 262: const { accessToken, orgUUID } = await prepareApiRequest() 263: const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}/events` 264: const headers = { 265: ...getOAuthHeaders(accessToken), 266: 'anthropic-beta': 'ccr-byoc-2025-07-29', 267: 'x-organization-uuid': orgUUID, 268: } 269: const userEvent = { 270: uuid: opts?.uuid ?? randomUUID(), 271: session_id: sessionId, 272: type: 'user', 273: parent_tool_use_id: null, 274: message: { 275: role: 'user', 276: content: messageContent, 277: }, 278: } 279: const requestBody = { 280: events: [userEvent], 281: } 282: logForDebugging( 283: `[sendEventToRemoteSession] Sending event to session ${sessionId}`, 284: ) 285: const response = await axios.post(url, requestBody, { 286: headers, 287: validateStatus: status => status < 500, 288: timeout: 30000, 289: }) 290: if (response.status === 200 || response.status === 201) { 291: logForDebugging( 292: `[sendEventToRemoteSession] Successfully sent event to session ${sessionId}`, 293: ) 294: return true 295: } 296: logForDebugging( 297: `[sendEventToRemoteSession] Failed with status ${response.status}: ${jsonStringify(response.data)}`, 298: ) 299: return false 300: } catch (error) { 301: logForDebugging(`[sendEventToRemoteSession] Error: ${errorMessage(error)}`) 302: return false 303: } 304: } 305: export async function updateSessionTitle( 306: sessionId: string, 307: title: string, 308: ): Promise<boolean> { 309: try { 310: const { accessToken, orgUUID } = await prepareApiRequest() 311: const url = `${getOauthConfig().BASE_API_URL}/v1/sessions/${sessionId}` 312: const headers = { 313: ...getOAuthHeaders(accessToken), 314: 'anthropic-beta': 'ccr-byoc-2025-07-29', 315: 'x-organization-uuid': orgUUID, 316: } 317: logForDebugging( 318: `[updateSessionTitle] Updating title for session ${sessionId}: "${title}"`, 319: ) 320: const response = await axios.patch( 321: url, 322: { title }, 323: { 324: headers, 325: validateStatus: status => status < 500, 326: }, 327: ) 328: if (response.status === 200) { 329: logForDebugging( 330: `[updateSessionTitle] Successfully updated title for session ${sessionId}`, 331: ) 332: return true 333: } 334: logForDebugging( 335: `[updateSessionTitle] Failed with status ${response.status}: ${jsonStringify(response.data)}`, 336: ) 337: return false 338: } catch (error) { 339: logForDebugging(`[updateSessionTitle] Error: ${errorMessage(error)}`) 340: return false 341: } 342: }

File: src/utils/teleport/environments.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig } from 'src/constants/oauth.js' 3: import { getOrganizationUUID } from 'src/services/oauth/client.js' 4: import { getClaudeAIOAuthTokens } from '../auth.js' 5: import { toError } from '../errors.js' 6: import { logError } from '../log.js' 7: import { getOAuthHeaders } from './api.js' 8: export type EnvironmentKind = 'anthropic_cloud' | 'byoc' | 'bridge' 9: export type EnvironmentState = 'active' 10: export type EnvironmentResource = { 11: kind: EnvironmentKind 12: environment_id: string 13: name: string 14: created_at: string 15: state: EnvironmentState 16: } 17: export type EnvironmentListResponse = { 18: environments: EnvironmentResource[] 19: has_more: boolean 20: first_id: string | null 21: last_id: string | null 22: } 23: export async function fetchEnvironments(): Promise<EnvironmentResource[]> { 24: const accessToken = getClaudeAIOAuthTokens()?.accessToken 25: if (!accessToken) { 26: throw new Error( 27: 'Claude Code web sessions require authentication with a Claude.ai account. API key authentication is not sufficient. Please run /login to authenticate, or check your authentication status with /status.', 28: ) 29: } 30: const orgUUID = await getOrganizationUUID() 31: if (!orgUUID) { 32: throw new Error('Unable to get organization UUID') 33: } 34: const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers` 35: try { 36: const headers = { 37: ...getOAuthHeaders(accessToken), 38: 'x-organization-uuid': orgUUID, 39: } 40: const response = await axios.get<EnvironmentListResponse>(url, { 41: headers, 42: timeout: 15000, 43: }) 44: if (response.status !== 200) { 45: throw new Error( 46: `Failed to fetch environments: ${response.status} ${response.statusText}`, 47: ) 48: } 49: return response.data.environments 50: } catch (error) { 51: const err = toError(error) 52: logError(err) 53: throw new Error(`Failed to fetch environments: ${err.message}`) 54: } 55: } 56: export async function createDefaultCloudEnvironment( 57: name: string, 58: ): Promise<EnvironmentResource> { 59: const accessToken = getClaudeAIOAuthTokens()?.accessToken 60: if (!accessToken) { 61: throw new Error('No access token available') 62: } 63: const orgUUID = await getOrganizationUUID() 64: if (!orgUUID) { 65: throw new Error('Unable to get organization UUID') 66: } 67: const url = `${getOauthConfig().BASE_API_URL}/v1/environment_providers/cloud/create` 68: const response = await axios.post<EnvironmentResource>( 69: url, 70: { 71: name, 72: kind: 'anthropic_cloud', 73: description: '', 74: config: { 75: environment_type: 'anthropic', 76: cwd: '/home/user', 77: init_script: null, 78: environment: {}, 79: languages: [ 80: { name: 'python', version: '3.11' }, 81: { name: 'node', version: '20' }, 82: ], 83: network_config: { 84: allowed_hosts: [], 85: allow_default_hosts: true, 86: }, 87: }, 88: }, 89: { 90: headers: { 91: ...getOAuthHeaders(accessToken), 92: 'anthropic-beta': 'ccr-byoc-2025-07-29', 93: 'x-organization-uuid': orgUUID, 94: }, 95: timeout: 15000, 96: }, 97: ) 98: return response.data 99: }

File: src/utils/teleport/environmentSelection.ts

typescript 1: import { SETTING_SOURCES, type SettingSource } from '../settings/constants.js' 2: import { 3: getSettings_DEPRECATED, 4: getSettingsForSource, 5: } from '../settings/settings.js' 6: import { type EnvironmentResource, fetchEnvironments } from './environments.js' 7: export type EnvironmentSelectionInfo = { 8: availableEnvironments: EnvironmentResource[] 9: selectedEnvironment: EnvironmentResource | null 10: selectedEnvironmentSource: SettingSource | null 11: } 12: export async function getEnvironmentSelectionInfo(): Promise<EnvironmentSelectionInfo> { 13: const environments = await fetchEnvironments() 14: if (environments.length === 0) { 15: return { 16: availableEnvironments: [], 17: selectedEnvironment: null, 18: selectedEnvironmentSource: null, 19: } 20: } 21: const mergedSettings = getSettings_DEPRECATED() 22: const defaultEnvironmentId = mergedSettings?.remote?.defaultEnvironmentId 23: let selectedEnvironment: EnvironmentResource = 24: environments.find(env => env.kind !== 'bridge') ?? environments[0]! 25: let selectedEnvironmentSource: SettingSource | null = null 26: if (defaultEnvironmentId) { 27: const matchingEnvironment = environments.find( 28: env => env.environment_id === defaultEnvironmentId, 29: ) 30: if (matchingEnvironment) { 31: selectedEnvironment = matchingEnvironment 32: for (let i = SETTING_SOURCES.length - 1; i >= 0; i--) { 33: const source = SETTING_SOURCES[i] 34: if (!source || source === 'flagSettings') { 35: continue 36: } 37: const sourceSettings = getSettingsForSource(source) 38: if ( 39: sourceSettings?.remote?.defaultEnvironmentId === defaultEnvironmentId 40: ) { 41: selectedEnvironmentSource = source 42: break 43: } 44: } 45: } 46: } 47: return { 48: availableEnvironments: environments, 49: selectedEnvironment, 50: selectedEnvironmentSource, 51: } 52: }

File: src/utils/teleport/gitBundle.ts

typescript 1: import { stat, unlink } from 'fs/promises' 2: import { 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: logEvent, 5: } from 'src/services/analytics/index.js' 6: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' 7: import { type FilesApiConfig, uploadFile } from '../../services/api/filesApi.js' 8: import { getCwd } from '../cwd.js' 9: import { logForDebugging } from '../debug.js' 10: import { execFileNoThrowWithCwd } from '../execFileNoThrow.js' 11: import { findGitRoot, gitExe } from '../git.js' 12: import { generateTempFilePath } from '../tempfile.js' 13: const DEFAULT_BUNDLE_MAX_BYTES = 100 * 1024 * 1024 14: type BundleScope = 'all' | 'head' | 'squashed' 15: export type BundleUploadResult = 16: | { 17: success: true 18: fileId: string 19: bundleSizeBytes: number 20: scope: BundleScope 21: hasWip: boolean 22: } 23: | { success: false; error: string; failReason?: BundleFailReason } 24: type BundleFailReason = 'git_error' | 'too_large' | 'empty_repo' 25: type BundleCreateResult = 26: | { ok: true; size: number; scope: BundleScope } 27: | { ok: false; error: string; failReason: BundleFailReason } 28: async function _bundleWithFallback( 29: gitRoot: string, 30: bundlePath: string, 31: maxBytes: number, 32: hasStash: boolean, 33: signal: AbortSignal | undefined, 34: ): Promise<BundleCreateResult> { 35: const extra = hasStash ? ['refs/seed/stash'] : [] 36: const mkBundle = (base: string) => 37: execFileNoThrowWithCwd( 38: gitExe(), 39: ['bundle', 'create', bundlePath, base, ...extra], 40: { cwd: gitRoot, abortSignal: signal }, 41: ) 42: const allResult = await mkBundle('--all') 43: if (allResult.code !== 0) { 44: return { 45: ok: false, 46: error: `git bundle create --all failed (${allResult.code}): ${allResult.stderr.slice(0, 200)}`, 47: failReason: 'git_error', 48: } 49: } 50: const { size: allSize } = await stat(bundlePath) 51: if (allSize <= maxBytes) { 52: return { ok: true, size: allSize, scope: 'all' } 53: } 54: logForDebugging( 55: `[gitBundle] --all bundle is ${(allSize / 1024 / 1024).toFixed(1)}MB (> ${(maxBytes / 1024 / 1024).toFixed(0)}MB), retrying HEAD-only`, 56: ) 57: const headResult = await mkBundle('HEAD') 58: if (headResult.code !== 0) { 59: return { 60: ok: false, 61: error: `git bundle create HEAD failed (${headResult.code}): ${headResult.stderr.slice(0, 200)}`, 62: failReason: 'git_error', 63: } 64: } 65: const { size: headSize } = await stat(bundlePath) 66: if (headSize <= maxBytes) { 67: return { ok: true, size: headSize, scope: 'head' } 68: } 69: logForDebugging( 70: `[gitBundle] HEAD bundle is ${(headSize / 1024 / 1024).toFixed(1)}MB, retrying squashed-root`, 71: ) 72: const treeRef = hasStash ? 'refs/seed/stash^{tree}' : 'HEAD^{tree}' 73: const commitTree = await execFileNoThrowWithCwd( 74: gitExe(), 75: ['commit-tree', treeRef, '-m', 'seed'], 76: { cwd: gitRoot, abortSignal: signal }, 77: ) 78: if (commitTree.code !== 0) { 79: return { 80: ok: false, 81: error: `git commit-tree failed (${commitTree.code}): ${commitTree.stderr.slice(0, 200)}`, 82: failReason: 'git_error', 83: } 84: } 85: const squashedSha = commitTree.stdout.trim() 86: await execFileNoThrowWithCwd( 87: gitExe(), 88: ['update-ref', 'refs/seed/root', squashedSha], 89: { cwd: gitRoot }, 90: ) 91: const squashResult = await execFileNoThrowWithCwd( 92: gitExe(), 93: ['bundle', 'create', bundlePath, 'refs/seed/root'], 94: { cwd: gitRoot, abortSignal: signal }, 95: ) 96: if (squashResult.code !== 0) { 97: return { 98: ok: false, 99: error: `git bundle create refs/seed/root failed (${squashResult.code}): ${squashResult.stderr.slice(0, 200)}`, 100: failReason: 'git_error', 101: } 102: } 103: const { size: squashSize } = await stat(bundlePath) 104: if (squashSize <= maxBytes) { 105: return { ok: true, size: squashSize, scope: 'squashed' } 106: } 107: return { 108: ok: false, 109: error: 110: 'Repo is too large to bundle. Please setup GitHub on https://claude.ai/code', 111: failReason: 'too_large', 112: } 113: } 114: export async function createAndUploadGitBundle( 115: config: FilesApiConfig, 116: opts?: { cwd?: string; signal?: AbortSignal }, 117: ): Promise<BundleUploadResult> { 118: const workdir = opts?.cwd ?? getCwd() 119: const gitRoot = findGitRoot(workdir) 120: if (!gitRoot) { 121: return { success: false, error: 'Not in a git repository' } 122: } 123: for (const ref of ['refs/seed/stash', 'refs/seed/root']) { 124: await execFileNoThrowWithCwd(gitExe(), ['update-ref', '-d', ref], { 125: cwd: gitRoot, 126: }) 127: } 128: const refCheck = await execFileNoThrowWithCwd( 129: gitExe(), 130: ['for-each-ref', '--count=1', 'refs/'], 131: { cwd: gitRoot }, 132: ) 133: if (refCheck.code === 0 && refCheck.stdout.trim() === '') { 134: logEvent('tengu_ccr_bundle_upload', { 135: outcome: 136: 'empty_repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 137: }) 138: return { 139: success: false, 140: error: 'Repository has no commits yet', 141: failReason: 'empty_repo', 142: } 143: } 144: const stashResult = await execFileNoThrowWithCwd( 145: gitExe(), 146: ['stash', 'create'], 147: { cwd: gitRoot, abortSignal: opts?.signal }, 148: ) 149: const wipStashSha = stashResult.code === 0 ? stashResult.stdout.trim() : '' 150: const hasWip = wipStashSha !== '' 151: if (stashResult.code !== 0) { 152: logForDebugging( 153: `[gitBundle] git stash create failed (${stashResult.code}), proceeding without WIP: ${stashResult.stderr.slice(0, 200)}`, 154: ) 155: } else if (hasWip) { 156: logForDebugging(`[gitBundle] Captured WIP as stash ${wipStashSha}`) 157: // env-runner reads the SHA via bundle list-heads refs/seed/stash. 158: await execFileNoThrowWithCwd( 159: gitExe(), 160: ['update-ref', 'refs/seed/stash', wipStashSha], 161: { cwd: gitRoot }, 162: ) 163: } 164: const bundlePath = generateTempFilePath('ccr-seed', '.bundle') 165: try { 166: const maxBytes = 167: getFeatureValue_CACHED_MAY_BE_STALE<number | null>( 168: 'tengu_ccr_bundle_max_bytes', 169: null, 170: ) ?? DEFAULT_BUNDLE_MAX_BYTES 171: const bundle = await _bundleWithFallback( 172: gitRoot, 173: bundlePath, 174: maxBytes, 175: hasWip, 176: opts?.signal, 177: ) 178: if (!bundle.ok) { 179: logForDebugging(`[gitBundle] ${bundle.error}`) 180: logEvent('tengu_ccr_bundle_upload', { 181: outcome: 182: bundle.failReason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 183: max_bytes: maxBytes, 184: }) 185: return { 186: success: false, 187: error: bundle.error, 188: failReason: bundle.failReason, 189: } 190: } 191: const upload = await uploadFile(bundlePath, '_source_seed.bundle', config, { 192: signal: opts?.signal, 193: }) 194: if (!upload.success) { 195: logEvent('tengu_ccr_bundle_upload', { 196: outcome: 197: 'failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 198: }) 199: return { success: false, error: upload.error } 200: } 201: logForDebugging( 202: `[gitBundle] Uploaded ${upload.size} bytes as file_id ${upload.fileId}`, 203: ) 204: logEvent('tengu_ccr_bundle_upload', { 205: outcome: 206: 'success' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 207: size_bytes: upload.size, 208: scope: 209: bundle.scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 210: has_wip: hasWip, 211: }) 212: return { 213: success: true, 214: fileId: upload.fileId, 215: bundleSizeBytes: upload.size, 216: scope: bundle.scope, 217: hasWip, 218: } 219: } finally { 220: try { 221: await unlink(bundlePath) 222: } catch { 223: logForDebugging(`[gitBundle] Could not delete ${bundlePath} (non-fatal)`) 224: } 225: for (const ref of ['refs/seed/stash', 'refs/seed/root']) { 226: await execFileNoThrowWithCwd(gitExe(), ['update-ref', '-d', ref], { 227: cwd: gitRoot, 228: }) 229: } 230: } 231: }

File: src/utils/todo/types.ts

typescript 1: import { z } from 'zod/v4' 2: import { lazySchema } from '../lazySchema.js' 3: const TodoStatusSchema = lazySchema(() => 4: z.enum(['pending', 'in_progress', 'completed']), 5: ) 6: export const TodoItemSchema = lazySchema(() => 7: z.object({ 8: content: z.string().min(1, 'Content cannot be empty'), 9: status: TodoStatusSchema(), 10: activeForm: z.string().min(1, 'Active form cannot be empty'), 11: }), 12: ) 13: export type TodoItem = z.infer<ReturnType<typeof TodoItemSchema>> 14: export const TodoListSchema = lazySchema(() => z.array(TodoItemSchema())) 15: export type TodoList = z.infer<ReturnType<typeof TodoListSchema>>

File: src/utils/ultraplan/ccrSession.ts

typescript 1: import type { 2: ToolResultBlockParam, 3: ToolUseBlock, 4: } from '@anthropic-ai/sdk/resources' 5: import type { SDKMessage } from '../../entrypoints/agentSdkTypes.js' 6: import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../tools/ExitPlanModeTool/constants.js' 7: import { logForDebugging } from '../debug.js' 8: import { sleep } from '../sleep.js' 9: import { isTransientNetworkError } from '../teleport/api.js' 10: import { 11: type PollRemoteSessionResponse, 12: pollRemoteSessionEvents, 13: } from '../teleport.js' 14: const POLL_INTERVAL_MS = 3000 15: const MAX_CONSECUTIVE_FAILURES = 5 16: export type PollFailReason = 17: | 'terminated' 18: | 'timeout_pending' 19: | 'timeout_no_plan' 20: | 'extract_marker_missing' 21: | 'network_or_unknown' 22: | 'stopped' 23: export class UltraplanPollError extends Error { 24: constructor( 25: message: string, 26: readonly reason: PollFailReason, 27: readonly rejectCount: number, 28: options?: ErrorOptions, 29: ) { 30: super(message, options) 31: this.name = 'UltraplanPollError' 32: } 33: } 34: export const ULTRAPLAN_TELEPORT_SENTINEL = '__ULTRAPLAN_TELEPORT_LOCAL__' 35: export type ScanResult = 36: | { kind: 'approved'; plan: string } 37: | { kind: 'teleport'; plan: string } 38: | { kind: 'rejected'; id: string } 39: | { kind: 'pending' } 40: | { kind: 'terminated'; subtype: string } 41: | { kind: 'unchanged' } 42: export type UltraplanPhase = 'running' | 'needs_input' | 'plan_ready' 43: export class ExitPlanModeScanner { 44: private exitPlanCalls: string[] = [] 45: private results = new Map<string, ToolResultBlockParam>() 46: private rejectedIds = new Set<string>() 47: private terminated: { subtype: string } | null = null 48: private rescanAfterRejection = false 49: everSeenPending = false 50: get rejectCount(): number { 51: return this.rejectedIds.size 52: } 53: get hasPendingPlan(): boolean { 54: const id = this.exitPlanCalls.findLast(c => !this.rejectedIds.has(c)) 55: return id !== undefined && !this.results.has(id) 56: } 57: ingest(newEvents: SDKMessage[]): ScanResult { 58: for (const m of newEvents) { 59: if (m.type === 'assistant') { 60: for (const block of m.message.content) { 61: if (block.type !== 'tool_use') continue 62: const tu = block as ToolUseBlock 63: if (tu.name === EXIT_PLAN_MODE_V2_TOOL_NAME) { 64: this.exitPlanCalls.push(tu.id) 65: } 66: } 67: } else if (m.type === 'user') { 68: const content = m.message.content 69: if (!Array.isArray(content)) continue 70: for (const block of content) { 71: if (block.type === 'tool_result') { 72: this.results.set(block.tool_use_id, block) 73: } 74: } 75: } else if (m.type === 'result' && m.subtype !== 'success') { 76: this.terminated = { subtype: m.subtype } 77: } 78: } 79: const shouldScan = newEvents.length > 0 || this.rescanAfterRejection 80: this.rescanAfterRejection = false 81: let found: 82: | { kind: 'approved'; plan: string } 83: | { kind: 'teleport'; plan: string } 84: | { kind: 'rejected'; id: string } 85: | { kind: 'pending' } 86: | null = null 87: if (shouldScan) { 88: for (let i = this.exitPlanCalls.length - 1; i >= 0; i--) { 89: const id = this.exitPlanCalls[i]! 90: if (this.rejectedIds.has(id)) continue 91: const tr = this.results.get(id) 92: if (!tr) { 93: found = { kind: 'pending' } 94: } else if (tr.is_error === true) { 95: const teleportPlan = extractTeleportPlan(tr.content) 96: found = 97: teleportPlan !== null 98: ? { kind: 'teleport', plan: teleportPlan } 99: : { kind: 'rejected', id } 100: } else { 101: found = { kind: 'approved', plan: extractApprovedPlan(tr.content) } 102: } 103: break 104: } 105: if (found?.kind === 'approved' || found?.kind === 'teleport') return found 106: } 107: if (found?.kind === 'rejected') { 108: this.rejectedIds.add(found.id) 109: this.rescanAfterRejection = true 110: } 111: if (this.terminated) { 112: return { kind: 'terminated', subtype: this.terminated.subtype } 113: } 114: if (found?.kind === 'rejected') { 115: return found 116: } 117: if (found?.kind === 'pending') { 118: this.everSeenPending = true 119: return found 120: } 121: return { kind: 'unchanged' } 122: } 123: } 124: export type PollResult = { 125: plan: string 126: rejectCount: number 127: executionTarget: 'local' | 'remote' 128: } 129: export async function pollForApprovedExitPlanMode( 130: sessionId: string, 131: timeoutMs: number, 132: onPhaseChange?: (phase: UltraplanPhase) => void, 133: shouldStop?: () => boolean, 134: ): Promise<PollResult> { 135: const deadline = Date.now() + timeoutMs 136: const scanner = new ExitPlanModeScanner() 137: let cursor: string | null = null 138: let failures = 0 139: let lastPhase: UltraplanPhase = 'running' 140: while (Date.now() < deadline) { 141: if (shouldStop?.()) { 142: throw new UltraplanPollError( 143: 'poll stopped by caller', 144: 'stopped', 145: scanner.rejectCount, 146: ) 147: } 148: let newEvents: SDKMessage[] 149: let sessionStatus: PollRemoteSessionResponse['sessionStatus'] 150: try { 151: const resp = await pollRemoteSessionEvents(sessionId, cursor) 152: newEvents = resp.newEvents 153: cursor = resp.lastEventId 154: sessionStatus = resp.sessionStatus 155: failures = 0 156: } catch (e) { 157: const transient = isTransientNetworkError(e) 158: if (!transient || ++failures >= MAX_CONSECUTIVE_FAILURES) { 159: throw new UltraplanPollError( 160: e instanceof Error ? e.message : String(e), 161: 'network_or_unknown', 162: scanner.rejectCount, 163: { cause: e }, 164: ) 165: } 166: await sleep(POLL_INTERVAL_MS) 167: continue 168: } 169: let result: ScanResult 170: try { 171: result = scanner.ingest(newEvents) 172: } catch (e) { 173: throw new UltraplanPollError( 174: e instanceof Error ? e.message : String(e), 175: 'extract_marker_missing', 176: scanner.rejectCount, 177: ) 178: } 179: if (result.kind === 'approved') { 180: return { 181: plan: result.plan, 182: rejectCount: scanner.rejectCount, 183: executionTarget: 'remote', 184: } 185: } 186: if (result.kind === 'teleport') { 187: return { 188: plan: result.plan, 189: rejectCount: scanner.rejectCount, 190: executionTarget: 'local', 191: } 192: } 193: if (result.kind === 'terminated') { 194: throw new UltraplanPollError( 195: `remote session ended (${result.subtype}) before plan approval`, 196: 'terminated', 197: scanner.rejectCount, 198: ) 199: } 200: const quietIdle = 201: (sessionStatus === 'idle' || sessionStatus === 'requires_action') && 202: newEvents.length === 0 203: const phase: UltraplanPhase = scanner.hasPendingPlan 204: ? 'plan_ready' 205: : quietIdle 206: ? 'needs_input' 207: : 'running' 208: if (phase !== lastPhase) { 209: logForDebugging(`[ultraplan] phase ${lastPhase} → ${phase}`) 210: lastPhase = phase 211: onPhaseChange?.(phase) 212: } 213: await sleep(POLL_INTERVAL_MS) 214: } 215: throw new UltraplanPollError( 216: scanner.everSeenPending 217: ? `no approval after ${timeoutMs / 1000}s` 218: : `ExitPlanMode never reached after ${timeoutMs / 1000}s (the remote container failed to start, or session ID mismatch?)`, 219: scanner.everSeenPending ? 'timeout_pending' : 'timeout_no_plan', 220: scanner.rejectCount, 221: ) 222: } 223: function contentToText(content: ToolResultBlockParam['content']): string { 224: return typeof content === 'string' 225: ? content 226: : Array.isArray(content) 227: ? content.map(b => ('text' in b ? b.text : '')).join('') 228: : '' 229: } 230: // Extracts the plan text after the ULTRAPLAN_TELEPORT_SENTINEL marker. 231: // Returns null when the sentinel is absent — callers treat null as a normal 232: // user rejection (scanner falls through to { kind: 'rejected' }). 233: function extractTeleportPlan( 234: content: ToolResultBlockParam['content'], 235: ): string | null { 236: const text = contentToText(content) 237: const marker = `${ULTRAPLAN_TELEPORT_SENTINEL}\n` 238: const idx = text.indexOf(marker) 239: if (idx === -1) return null 240: return text.slice(idx + marker.length).trimEnd() 241: } 242: function extractApprovedPlan(content: ToolResultBlockParam['content']): string { 243: const text = contentToText(content) 244: const markers = [ 245: '## Approved Plan (edited by user):\n', 246: '## Approved Plan:\n', 247: ] 248: for (const marker of markers) { 249: const idx = text.indexOf(marker) 250: if (idx !== -1) { 251: return text.slice(idx + marker.length).trimEnd() 252: } 253: } 254: throw new Error( 255: `ExitPlanMode approved but tool_result has no "## Approved Plan:" marker — remote may have hit the empty-plan or isAgent branch. Content preview: ${text.slice(0, 200)}`, 256: ) 257: }

File: src/utils/ultraplan/keyword.ts

typescript 1: type TriggerPosition = { word: string; start: number; end: number } 2: const OPEN_TO_CLOSE: Record<string, string> = { 3: '`': '`', 4: '"': '"', 5: '<': '>', 6: '{': '}', 7: '[': ']', 8: '(': ')', 9: "'": "'", 10: } 11: function findKeywordTriggerPositions( 12: text: string, 13: keyword: string, 14: ): TriggerPosition[] { 15: const re = new RegExp(keyword, 'i') 16: if (!re.test(text)) return [] 17: if (text.startsWith('/')) return [] 18: const quotedRanges: Array<{ start: number; end: number }> = [] 19: let openQuote: string | null = null 20: let openAt = 0 21: const isWord = (ch: string | undefined) => !!ch && /[\p{L}\p{N}_]/u.test(ch) 22: for (let i = 0; i < text.length; i++) { 23: const ch = text[i]! 24: if (openQuote) { 25: if (openQuote === '[' && ch === '[') { 26: openAt = i 27: continue 28: } 29: if (ch !== OPEN_TO_CLOSE[openQuote]) continue 30: if (openQuote === "'" && isWord(text[i + 1])) continue 31: quotedRanges.push({ start: openAt, end: i + 1 }) 32: openQuote = null 33: } else if ( 34: (ch === '<' && i + 1 < text.length && /[a-zA-Z/]/.test(text[i + 1]!)) || 35: (ch === "'" && !isWord(text[i - 1])) || 36: (ch !== '<' && ch !== "'" && ch in OPEN_TO_CLOSE) 37: ) { 38: openQuote = ch 39: openAt = i 40: } 41: } 42: const positions: TriggerPosition[] = [] 43: const wordRe = new RegExp(`\\b${keyword}\\b`, 'gi') 44: const matches = text.matchAll(wordRe) 45: for (const match of matches) { 46: if (match.index === undefined) continue 47: const start = match.index 48: const end = start + match[0].length 49: if (quotedRanges.some(r => start >= r.start && start < r.end)) continue 50: const before = text[start - 1] 51: const after = text[end] 52: if (before === '/' || before === '\\' || before === '-') continue 53: if (after === '/' || after === '\\' || after === '-' || after === '?') 54: continue 55: if (after === '.' && isWord(text[end + 1])) continue 56: positions.push({ word: match[0], start, end }) 57: } 58: return positions 59: } 60: export function findUltraplanTriggerPositions(text: string): TriggerPosition[] { 61: return findKeywordTriggerPositions(text, 'ultraplan') 62: } 63: export function findUltrareviewTriggerPositions( 64: text: string, 65: ): TriggerPosition[] { 66: return findKeywordTriggerPositions(text, 'ultrareview') 67: } 68: export function hasUltraplanKeyword(text: string): boolean { 69: return findUltraplanTriggerPositions(text).length > 0 70: } 71: export function hasUltrareviewKeyword(text: string): boolean { 72: return findUltrareviewTriggerPositions(text).length > 0 73: } 74: export function replaceUltraplanKeyword(text: string): string { 75: const [trigger] = findUltraplanTriggerPositions(text) 76: if (!trigger) return text 77: const before = text.slice(0, trigger.start) 78: const after = text.slice(trigger.end) 79: if (!(before + after).trim()) return '' 80: return before + trigger.word.slice('ultra'.length) + after 81: }

File: src/utils/abortController.ts

typescript 1: import { setMaxListeners } from 'events' 2: const DEFAULT_MAX_LISTENERS = 50 3: export function createAbortController( 4: maxListeners: number = DEFAULT_MAX_LISTENERS, 5: ): AbortController { 6: const controller = new AbortController() 7: setMaxListeners(maxListeners, controller.signal) 8: return controller 9: } 10: function propagateAbort( 11: this: WeakRef<AbortController>, 12: weakChild: WeakRef<AbortController>, 13: ): void { 14: const parent = this.deref() 15: weakChild.deref()?.abort(parent?.signal.reason) 16: } 17: function removeAbortHandler( 18: this: WeakRef<AbortController>, 19: weakHandler: WeakRef<(...args: unknown[]) => void>, 20: ): void { 21: const parent = this.deref() 22: const handler = weakHandler.deref() 23: if (parent && handler) { 24: parent.signal.removeEventListener('abort', handler) 25: } 26: } 27: export function createChildAbortController( 28: parent: AbortController, 29: maxListeners?: number, 30: ): AbortController { 31: const child = createAbortController(maxListeners) 32: if (parent.signal.aborted) { 33: child.abort(parent.signal.reason) 34: return child 35: } 36: const weakChild = new WeakRef(child) 37: const weakParent = new WeakRef(parent) 38: const handler = propagateAbort.bind(weakParent, weakChild) 39: parent.signal.addEventListener('abort', handler, { once: true }) 40: child.signal.addEventListener( 41: 'abort', 42: removeAbortHandler.bind(weakParent, new WeakRef(handler)), 43: { once: true }, 44: ) 45: return child 46: }

File: src/utils/activityManager.ts

typescript 1: import { getActiveTimeCounter as getActiveTimeCounterImpl } from '../bootstrap/state.js' 2: type ActivityManagerOptions = { 3: getNow?: () => number 4: getActiveTimeCounter?: typeof getActiveTimeCounterImpl 5: } 6: export class ActivityManager { 7: private activeOperations = new Set<string>() 8: private lastUserActivityTime: number = 0 9: private lastCLIRecordedTime: number 10: private isCLIActive: boolean = false 11: private readonly USER_ACTIVITY_TIMEOUT_MS = 5000 12: private readonly getNow: () => number 13: private readonly getActiveTimeCounter: typeof getActiveTimeCounterImpl 14: private static instance: ActivityManager | null = null 15: constructor(options?: ActivityManagerOptions) { 16: this.getNow = options?.getNow ?? (() => Date.now()) 17: this.getActiveTimeCounter = 18: options?.getActiveTimeCounter ?? getActiveTimeCounterImpl 19: this.lastCLIRecordedTime = this.getNow() 20: } 21: static getInstance(): ActivityManager { 22: if (!ActivityManager.instance) { 23: ActivityManager.instance = new ActivityManager() 24: } 25: return ActivityManager.instance 26: } 27: static resetInstance(): void { 28: ActivityManager.instance = null 29: } 30: static createInstance(options?: ActivityManagerOptions): ActivityManager { 31: ActivityManager.instance = new ActivityManager(options) 32: return ActivityManager.instance 33: } 34: recordUserActivity(): void { 35: if (!this.isCLIActive && this.lastUserActivityTime !== 0) { 36: const now = this.getNow() 37: const timeSinceLastActivity = (now - this.lastUserActivityTime) / 1000 38: if (timeSinceLastActivity > 0) { 39: const activeTimeCounter = this.getActiveTimeCounter() 40: if (activeTimeCounter) { 41: const timeoutSeconds = this.USER_ACTIVITY_TIMEOUT_MS / 1000 42: if (timeSinceLastActivity < timeoutSeconds) { 43: activeTimeCounter.add(timeSinceLastActivity, { type: 'user' }) 44: } 45: } 46: } 47: } 48: this.lastUserActivityTime = this.getNow() 49: } 50: startCLIActivity(operationId: string): void { 51: if (this.activeOperations.has(operationId)) { 52: this.endCLIActivity(operationId) 53: } 54: const wasEmpty = this.activeOperations.size === 0 55: this.activeOperations.add(operationId) 56: if (wasEmpty) { 57: this.isCLIActive = true 58: this.lastCLIRecordedTime = this.getNow() 59: } 60: } 61: endCLIActivity(operationId: string): void { 62: this.activeOperations.delete(operationId) 63: if (this.activeOperations.size === 0) { 64: const now = this.getNow() 65: const timeSinceLastRecord = (now - this.lastCLIRecordedTime) / 1000 66: if (timeSinceLastRecord > 0) { 67: const activeTimeCounter = this.getActiveTimeCounter() 68: if (activeTimeCounter) { 69: activeTimeCounter.add(timeSinceLastRecord, { type: 'cli' }) 70: } 71: } 72: this.lastCLIRecordedTime = now 73: this.isCLIActive = false 74: } 75: } 76: async trackOperation<T>( 77: operationId: string, 78: fn: () => Promise<T>, 79: ): Promise<T> { 80: this.startCLIActivity(operationId) 81: try { 82: return await fn() 83: } finally { 84: this.endCLIActivity(operationId) 85: } 86: } 87: getActivityStates(): { 88: isUserActive: boolean 89: isCLIActive: boolean 90: activeOperationCount: number 91: } { 92: const now = this.getNow() 93: const timeSinceUserActivity = (now - this.lastUserActivityTime) / 1000 94: const isUserActive = 95: timeSinceUserActivity < this.USER_ACTIVITY_TIMEOUT_MS / 1000 96: return { 97: isUserActive, 98: isCLIActive: this.isCLIActive, 99: activeOperationCount: this.activeOperations.size, 100: } 101: } 102: } 103: export const activityManager = ActivityManager.getInstance()

File: src/utils/advisor.ts

typescript 1: import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 3: import { shouldIncludeFirstPartyOnlyBetas } from './betas.js' 4: import { isEnvTruthy } from './envUtils.js' 5: import { getInitialSettings } from './settings/settings.js' 6: export type AdvisorServerToolUseBlock = { 7: type: 'server_tool_use' 8: id: string 9: name: 'advisor' 10: input: { [key: string]: unknown } 11: } 12: export type AdvisorToolResultBlock = { 13: type: 'advisor_tool_result' 14: tool_use_id: string 15: content: 16: | { 17: type: 'advisor_result' 18: text: string 19: } 20: | { 21: type: 'advisor_redacted_result' 22: encrypted_content: string 23: } 24: | { 25: type: 'advisor_tool_result_error' 26: error_code: string 27: } 28: } 29: export type AdvisorBlock = AdvisorServerToolUseBlock | AdvisorToolResultBlock 30: export function isAdvisorBlock(param: { 31: type: string 32: name?: string 33: }): param is AdvisorBlock { 34: return ( 35: param.type === 'advisor_tool_result' || 36: (param.type === 'server_tool_use' && param.name === 'advisor') 37: ) 38: } 39: type AdvisorConfig = { 40: enabled?: boolean 41: canUserConfigure?: boolean 42: baseModel?: string 43: advisorModel?: string 44: } 45: function getAdvisorConfig(): AdvisorConfig { 46: return getFeatureValue_CACHED_MAY_BE_STALE<AdvisorConfig>( 47: 'tengu_sage_compass', 48: {}, 49: ) 50: } 51: export function isAdvisorEnabled(): boolean { 52: if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ADVISOR_TOOL)) { 53: return false 54: } 55: if (!shouldIncludeFirstPartyOnlyBetas()) { 56: return false 57: } 58: return getAdvisorConfig().enabled ?? false 59: } 60: export function canUserConfigureAdvisor(): boolean { 61: return isAdvisorEnabled() && (getAdvisorConfig().canUserConfigure ?? false) 62: } 63: export function getExperimentAdvisorModels(): 64: | { baseModel: string; advisorModel: string } 65: | undefined { 66: const config = getAdvisorConfig() 67: return isAdvisorEnabled() && 68: !canUserConfigureAdvisor() && 69: config.baseModel && 70: config.advisorModel 71: ? { baseModel: config.baseModel, advisorModel: config.advisorModel } 72: : undefined 73: } 74: export function modelSupportsAdvisor(model: string): boolean { 75: const m = model.toLowerCase() 76: return ( 77: m.includes('opus-4-6') || 78: m.includes('sonnet-4-6') || 79: process.env.USER_TYPE === 'ant' 80: ) 81: } 82: export function isValidAdvisorModel(model: string): boolean { 83: const m = model.toLowerCase() 84: return ( 85: m.includes('opus-4-6') || 86: m.includes('sonnet-4-6') || 87: process.env.USER_TYPE === 'ant' 88: ) 89: } 90: export function getInitialAdvisorSetting(): string | undefined { 91: if (!isAdvisorEnabled()) { 92: return undefined 93: } 94: return getInitialSettings().advisorModel 95: } 96: export function getAdvisorUsage( 97: usage: BetaUsage, 98: ): Array<BetaUsage & { model: string }> { 99: const iterations = usage.iterations as 100: | Array<{ type: string }> 101: | null 102: | undefined 103: if (!iterations) { 104: return [] 105: } 106: return iterations.filter( 107: it => it.type === 'advisor_message', 108: ) as unknown as Array<BetaUsage & { model: string }> 109: } 110: export const ADVISOR_TOOL_INSTRUCTIONS = `# Advisor Tool 111: You have access to an \`advisor\` tool backed by a stronger reviewer model. It takes NO parameters -- when you call it, your entire conversation history is automatically forwarded. The advisor sees the task, every tool call you've made, every result you've seen. 112: Call advisor BEFORE substantive work -- before writing code, before committing to an interpretation, before building on an assumption. If the task requires orientation first (finding files, reading code, seeing what's there), do that, then call advisor. Orientation is not substantive work. Writing, editing, and declaring an answer are. 113: Also call advisor: 114: - When you believe the task is complete. BEFORE this call, make your deliverable durable: write the file, stage the change, save the result. The advisor call takes time; if the session ends during it, a durable result persists and an unwritten one doesn't. 115: - When stuck -- errors recurring, approach not converging, results that don't fit. 116: - When considering a change of approach. 117: On tasks longer than a few steps, call advisor at least once before committing to an approach and once before declaring done. On short reactive tasks where the next action is dictated by tool output you just read, you don't need to keep calling -- the advisor adds most of its value on the first call, before the approach crystallizes. 118: Give the advice serious weight. If you follow a step and it fails empirically, or you have primary-source evidence that contradicts a specific claim (the file says X, the code does Y), adapt. A passing self-test is not evidence the advice is wrong -- it's evidence your test doesn't check what the advice is checking. 119: If you've already retrieved data pointing one way and the advisor points another: don't silently switch. Surface the conflict in one more advisor call -- "I found X, you suggest Y, which constraint breaks the tie?" The advisor saw your evidence but may have underweighted it; a reconcile call is cheaper than committing to the wrong branch.`

File: src/utils/agentContext.ts

typescript 1: import { AsyncLocalStorage } from 'async_hooks' 2: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from '../services/analytics/index.js' 3: import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js' 4: export type SubagentContext = { 5: agentId: string 6: parentSessionId?: string 7: agentType: 'subagent' 8: subagentName?: string 9: isBuiltIn?: boolean 10: invokingRequestId?: string 11: invocationKind?: 'spawn' | 'resume' 12: invocationEmitted?: boolean 13: } 14: export type TeammateAgentContext = { 15: agentId: string 16: agentName: string 17: teamName: string 18: agentColor?: string 19: planModeRequired: boolean 20: parentSessionId: string 21: isTeamLead: boolean 22: agentType: 'teammate' 23: invokingRequestId?: string 24: invocationKind?: 'spawn' | 'resume' 25: invocationEmitted?: boolean 26: } 27: export type AgentContext = SubagentContext | TeammateAgentContext 28: const agentContextStorage = new AsyncLocalStorage<AgentContext>() 29: export function getAgentContext(): AgentContext | undefined { 30: return agentContextStorage.getStore() 31: } 32: export function runWithAgentContext<T>(context: AgentContext, fn: () => T): T { 33: return agentContextStorage.run(context, fn) 34: } 35: export function isSubagentContext( 36: context: AgentContext | undefined, 37: ): context is SubagentContext { 38: return context?.agentType === 'subagent' 39: } 40: export function isTeammateAgentContext( 41: context: AgentContext | undefined, 42: ): context is TeammateAgentContext { 43: if (isAgentSwarmsEnabled()) { 44: return context?.agentType === 'teammate' 45: } 46: return false 47: } 48: export function getSubagentLogName(): 49: | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 50: | undefined { 51: const context = getAgentContext() 52: if (!isSubagentContext(context) || !context.subagentName) { 53: return undefined 54: } 55: return ( 56: context.isBuiltIn ? context.subagentName : 'user-defined' 57: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 58: } 59: export function consumeInvokingRequestId(): 60: | { 61: invokingRequestId: string 62: invocationKind: 'spawn' | 'resume' | undefined 63: } 64: | undefined { 65: const context = getAgentContext() 66: if (!context?.invokingRequestId || context.invocationEmitted) { 67: return undefined 68: } 69: context.invocationEmitted = true 70: return { 71: invokingRequestId: context.invokingRequestId, 72: invocationKind: context.invocationKind, 73: } 74: }

File: src/utils/agenticSessionSearch.ts

typescript 1: import type { LogOption, SerializedMessage } from '../types/logs.js' 2: import { count } from './array.js' 3: import { logForDebugging } from './debug.js' 4: import { getLogDisplayTitle, logError } from './log.js' 5: import { getSmallFastModel } from './model/model.js' 6: import { isLiteLog, loadFullLog } from './sessionStorage.js' 7: import { sideQuery } from './sideQuery.js' 8: import { jsonParse } from './slowOperations.js' 9: const MAX_TRANSCRIPT_CHARS = 2000 10: const MAX_MESSAGES_TO_SCAN = 100 11: const MAX_SESSIONS_TO_SEARCH = 100 12: const SESSION_SEARCH_SYSTEM_PROMPT = `Your goal is to find relevant sessions based on a user's search query. 13: You will be given a list of sessions with their metadata and a search query. Identify which sessions are most relevant to the query. 14: Each session may include: 15: - Title (display name or custom title) 16: - Tag (user-assigned category, shown as [tag: name] - users tag sessions with /tag command to categorize them) 17: - Branch (git branch name, shown as [branch: name]) 18: - Summary (AI-generated summary) 19: - First message (beginning of the conversation) 20: - Transcript (excerpt of conversation content) 21: IMPORTANT: Tags are user-assigned labels that indicate the session's topic or category. If the query matches a tag exactly or partially, those sessions should be highly prioritized. 22: For each session, consider (in order of priority): 23: 1. Exact tag matches (highest priority - user explicitly categorized this session) 24: 2. Partial tag matches or tag-related terms 25: 3. Title matches (custom titles or first message content) 26: 4. Branch name matches 27: 5. Summary and transcript content matches 28: 6. Semantic similarity and related concepts 29: CRITICAL: Be VERY inclusive in your matching. Include sessions that: 30: - Contain the query term anywhere in any field 31: - Are semantically related to the query (e.g., "testing" matches sessions about "tests", "unit tests", "QA", etc.) 32: - Discuss topics that could be related to the query 33: - Have transcripts that mention the concept even in passing 34: When in doubt, INCLUDE the session. It's better to return too many results than too few. The user can easily scan through results, but missing relevant sessions is frustrating. 35: Return sessions ordered by relevance (most relevant first). If truly no sessions have ANY connection to the query, return an empty array - but this should be rare. 36: Respond with ONLY the JSON object, no markdown formatting: 37: {"relevant_indices": [2, 5, 0]}` 38: type AgenticSearchResult = { 39: relevant_indices: number[] 40: } 41: function extractMessageText(message: SerializedMessage): string { 42: if (message.type !== 'user' && message.type !== 'assistant') { 43: return '' 44: } 45: const content = 'message' in message ? message.message?.content : undefined 46: if (!content) return '' 47: if (typeof content === 'string') { 48: return content 49: } 50: if (Array.isArray(content)) { 51: return content 52: .map(block => { 53: if (typeof block === 'string') return block 54: if ('text' in block && typeof block.text === 'string') return block.text 55: return '' 56: }) 57: .filter(Boolean) 58: .join(' ') 59: } 60: return '' 61: } 62: /** 63: * Extracts a truncated transcript from session messages. 64: */ 65: function extractTranscript(messages: SerializedMessage[]): string { 66: if (messages.length === 0) return '' 67: // Take messages from start and end to get context 68: const messagesToScan = 69: messages.length <= MAX_MESSAGES_TO_SCAN 70: ? messages 71: : [ 72: ...messages.slice(0, MAX_MESSAGES_TO_SCAN / 2), 73: ...messages.slice(-MAX_MESSAGES_TO_SCAN / 2), 74: ] 75: const text = messagesToScan 76: .map(extractMessageText) 77: .filter(Boolean) 78: .join(' ') 79: .replace(/\s+/g, ' ') 80: .trim() 81: return text.length > MAX_TRANSCRIPT_CHARS 82: ? text.slice(0, MAX_TRANSCRIPT_CHARS) + '…' 83: : text 84: } 85: /** 86: * Checks if a log contains the query term in any searchable field. 87: */ 88: function logContainsQuery(log: LogOption, queryLower: string): boolean { 89: // Check title 90: const title = getLogDisplayTitle(log).toLowerCase() 91: if (title.includes(queryLower)) return true 92: // Check custom title 93: if (log.customTitle?.toLowerCase().includes(queryLower)) return true 94: // Check tag 95: if (log.tag?.toLowerCase().includes(queryLower)) return true 96: // Check branch 97: if (log.gitBranch?.toLowerCase().includes(queryLower)) return true 98: // Check summary 99: if (log.summary?.toLowerCase().includes(queryLower)) return true 100: // Check first prompt 101: if (log.firstPrompt?.toLowerCase().includes(queryLower)) return true 102: // Check transcript (more expensive, do last) 103: if (log.messages && log.messages.length > 0) { 104: const transcript = extractTranscript(log.messages).toLowerCase() 105: if (transcript.includes(queryLower)) return true 106: } 107: return false 108: } 109: /** 110: * Performs an agentic search using Claude to find relevant sessions 111: * based on semantic understanding of the query. 112: */ 113: export async function agenticSessionSearch( 114: query: string, 115: logs: LogOption[], 116: signal?: AbortSignal, 117: ): Promise<LogOption[]> { 118: if (!query.trim() || logs.length === 0) { 119: return [] 120: } 121: const queryLower = query.toLowerCase() 122: // Pre-filter: find sessions that contain the query term 123: // This ensures we search relevant sessions, not just recent ones 124: const matchingLogs = logs.filter(log => logContainsQuery(log, queryLower)) 125: // Take up to MAX_SESSIONS_TO_SEARCH matching logs 126: // If fewer matches, fill remaining slots with recent non-matching logs for context 127: let logsToSearch: LogOption[] 128: if (matchingLogs.length >= MAX_SESSIONS_TO_SEARCH) { 129: logsToSearch = matchingLogs.slice(0, MAX_SESSIONS_TO_SEARCH) 130: } else { 131: const nonMatchingLogs = logs.filter( 132: log => !logContainsQuery(log, queryLower), 133: ) 134: const remainingSlots = MAX_SESSIONS_TO_SEARCH - matchingLogs.length 135: logsToSearch = [ 136: ...matchingLogs, 137: ...nonMatchingLogs.slice(0, remainingSlots), 138: ] 139: } 140: // Debug: log what data we have 141: logForDebugging( 142: `Agentic search: ${logsToSearch.length}/${logs.length} logs, query="${query}", ` + 143: `matching: ${matchingLogs.length}, with messages: ${count(logsToSearch, l => l.messages?.length > 0)}`, 144: ) 145: // Load full logs for lite logs to get transcript content 146: const logsWithTranscriptsPromises = logsToSearch.map(async log => { 147: if (isLiteLog(log)) { 148: try { 149: return await loadFullLog(log) 150: } catch (error) { 151: logError(error as Error) 152: // If loading fails, use the lite log (no transcript) 153: return log 154: } 155: } 156: return log 157: }) 158: const logsWithTranscripts = await Promise.all(logsWithTranscriptsPromises) 159: logForDebugging( 160: `Agentic search: loaded ${count(logsWithTranscripts, l => l.messages?.length > 0)}/${logsToSearch.length} logs with transcripts`, 161: ) 162: // Build session list for the prompt with all searchable metadata 163: const sessionList = logsWithTranscripts 164: .map((log, index) => { 165: const parts: string[] = [`${index}:`] 166: // Title (display title, may be custom or from first prompt) 167: const displayTitle = getLogDisplayTitle(log) 168: parts.push(displayTitle) 169: // Custom title if different from display title 170: if (log.customTitle && log.customTitle !== displayTitle) { 171: parts.push(`[custom title: ${log.customTitle}]`) 172: } 173: // Tag 174: if (log.tag) { 175: parts.push(`[tag: ${log.tag}]`) 176: } 177: // Git branch 178: if (log.gitBranch) { 179: parts.push(`[branch: ${log.gitBranch}]`) 180: } 181: // Summary 182: if (log.summary) { 183: parts.push(`- Summary: ${log.summary}`) 184: } 185: // First prompt content (truncated) 186: if (log.firstPrompt && log.firstPrompt !== 'No prompt') { 187: parts.push(`- First message: ${log.firstPrompt.slice(0, 300)}`) 188: } 189: if (log.messages && log.messages.length > 0) { 190: const transcript = extractTranscript(log.messages) 191: if (transcript) { 192: parts.push(`- Transcript: ${transcript}`) 193: } 194: } 195: return parts.join(' ') 196: }) 197: .join('\n') 198: const userMessage = `Sessions: 199: ${sessionList} 200: Search query: "${query}" 201: Find the sessions that are most relevant to this query.` 202: logForDebugging( 203: `Agentic search prompt (first 500 chars): ${userMessage.slice(0, 500)}...`, 204: ) 205: try { 206: const model = getSmallFastModel() 207: logForDebugging(`Agentic search using model: ${model}`) 208: const response = await sideQuery({ 209: model, 210: system: SESSION_SEARCH_SYSTEM_PROMPT, 211: messages: [{ role: 'user', content: userMessage }], 212: signal, 213: querySource: 'session_search', 214: }) 215: const textContent = response.content.find(block => block.type === 'text') 216: if (!textContent || textContent.type !== 'text') { 217: logForDebugging('No text content in agentic search response') 218: return [] 219: } 220: logForDebugging(`Agentic search response: ${textContent.text}`) 221: const jsonMatch = textContent.text.match(/\{[\s\S]*\}/) 222: if (!jsonMatch) { 223: logForDebugging('Could not find JSON in agentic search response') 224: return [] 225: } 226: const result: AgenticSearchResult = jsonParse(jsonMatch[0]) 227: const relevantIndices = result.relevant_indices || [] 228: const relevantLogs = relevantIndices 229: .filter(index => index >= 0 && index < logsWithTranscripts.length) 230: .map(index => logsWithTranscripts[index]!) 231: logForDebugging( 232: `Agentic search found ${relevantLogs.length} relevant sessions`, 233: ) 234: return relevantLogs 235: } catch (error) { 236: logError(error as Error) 237: logForDebugging(`Agentic search error: ${error}`) 238: return [] 239: } 240: }

File: src/utils/agentId.ts

typescript 1: export function formatAgentId(agentName: string, teamName: string): string { 2: return `${agentName}@${teamName}` 3: } 4: export function parseAgentId( 5: agentId: string, 6: ): { agentName: string; teamName: string } | null { 7: const atIndex = agentId.indexOf('@') 8: if (atIndex === -1) { 9: return null 10: } 11: return { 12: agentName: agentId.slice(0, atIndex), 13: teamName: agentId.slice(atIndex + 1), 14: } 15: } 16: export function generateRequestId( 17: requestType: string, 18: agentId: string, 19: ): string { 20: const timestamp = Date.now() 21: return `${requestType}-${timestamp}@${agentId}` 22: } 23: export function parseRequestId( 24: requestId: string, 25: ): { requestType: string; timestamp: number; agentId: string } | null { 26: const atIndex = requestId.indexOf('@') 27: if (atIndex === -1) { 28: return null 29: } 30: const prefix = requestId.slice(0, atIndex) 31: const agentId = requestId.slice(atIndex + 1) 32: const lastDashIndex = prefix.lastIndexOf('-') 33: if (lastDashIndex === -1) { 34: return null 35: } 36: const requestType = prefix.slice(0, lastDashIndex) 37: const timestampStr = prefix.slice(lastDashIndex + 1) 38: const timestamp = parseInt(timestampStr, 10) 39: if (isNaN(timestamp)) { 40: return null 41: } 42: return { requestType, timestamp, agentId } 43: }

File: src/utils/agentSwarmsEnabled.ts

typescript 1: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 2: import { isEnvTruthy } from './envUtils.js' 3: function isAgentTeamsFlagSet(): boolean { 4: return process.argv.includes('--agent-teams') 5: } 6: export function isAgentSwarmsEnabled(): boolean { 7: if (process.env.USER_TYPE === 'ant') { 8: return true 9: } 10: if ( 11: !isEnvTruthy(process.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS) && 12: !isAgentTeamsFlagSet() 13: ) { 14: return false 15: } 16: if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_flint', true)) { 17: return false 18: } 19: return true 20: }

File: src/utils/analyzeContext.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { Anthropic } from '@anthropic-ai/sdk' 3: import { 4: getSystemPrompt, 5: SYSTEM_PROMPT_DYNAMIC_BOUNDARY, 6: } from 'src/constants/prompts.js' 7: import { microcompactMessages } from 'src/services/compact/microCompact.js' 8: import { getSdkBetas } from '../bootstrap/state.js' 9: import { getCommandName } from '../commands.js' 10: import { getSystemContext } from '../context.js' 11: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 12: import { 13: AUTOCOMPACT_BUFFER_TOKENS, 14: getEffectiveContextWindowSize, 15: isAutoCompactEnabled, 16: MANUAL_COMPACT_BUFFER_TOKENS, 17: } from '../services/compact/autoCompact.js' 18: import { 19: countMessagesTokensWithAPI, 20: countTokensViaHaikuFallback, 21: roughTokenCountEstimation, 22: } from '../services/tokenEstimation.js' 23: import { estimateSkillFrontmatterTokens } from '../skills/loadSkillsDir.js' 24: import { 25: findToolByName, 26: type Tool, 27: type ToolPermissionContext, 28: type Tools, 29: type ToolUseContext, 30: toolMatchesName, 31: } from '../Tool.js' 32: import type { 33: AgentDefinition, 34: AgentDefinitionsResult, 35: } from '../tools/AgentTool/loadAgentsDir.js' 36: import { SKILL_TOOL_NAME } from '../tools/SkillTool/constants.js' 37: import { 38: getLimitedSkillToolCommands, 39: getSkillToolInfo as getSlashCommandInfo, 40: } from '../tools/SkillTool/prompt.js' 41: import type { 42: AssistantMessage, 43: AttachmentMessage, 44: Message, 45: NormalizedAssistantMessage, 46: NormalizedUserMessage, 47: UserMessage, 48: } from '../types/message.js' 49: import { toolToAPISchema } from './api.js' 50: import { filterInjectedMemoryFiles, getMemoryFiles } from './claudemd.js' 51: import { getContextWindowForModel } from './context.js' 52: import { getCwd } from './cwd.js' 53: import { logForDebugging } from './debug.js' 54: import { isEnvTruthy } from './envUtils.js' 55: import { errorMessage, toError } from './errors.js' 56: import { logError } from './log.js' 57: import { normalizeMessagesForAPI } from './messages.js' 58: import { getRuntimeMainLoopModel } from './model/model.js' 59: import type { SettingSource } from './settings/constants.js' 60: import { jsonStringify } from './slowOperations.js' 61: import { buildEffectiveSystemPrompt } from './systemPrompt.js' 62: import type { Theme } from './theme.js' 63: import { getCurrentUsage } from './tokens.js' 64: const RESERVED_CATEGORY_NAME = 'Autocompact buffer' 65: const MANUAL_COMPACT_BUFFER_NAME = 'Compact buffer' 66: export const TOOL_TOKEN_COUNT_OVERHEAD = 500 67: async function countTokensWithFallback( 68: messages: Anthropic.Beta.Messages.BetaMessageParam[], 69: tools: Anthropic.Beta.Messages.BetaToolUnion[], 70: ): Promise<number | null> { 71: try { 72: const result = await countMessagesTokensWithAPI(messages, tools) 73: if (result !== null) { 74: return result 75: } 76: logForDebugging( 77: `countTokensWithFallback: API returned null, trying haiku fallback (${tools.length} tools)`, 78: ) 79: } catch (err) { 80: logForDebugging(`countTokensWithFallback: API failed: ${errorMessage(err)}`) 81: logError(err) 82: } 83: try { 84: const fallbackResult = await countTokensViaHaikuFallback(messages, tools) 85: if (fallbackResult === null) { 86: logForDebugging( 87: `countTokensWithFallback: haiku fallback also returned null (${tools.length} tools)`, 88: ) 89: } 90: return fallbackResult 91: } catch (err) { 92: logForDebugging( 93: `countTokensWithFallback: haiku fallback failed: ${errorMessage(err)}`, 94: ) 95: logError(err) 96: return null 97: } 98: } 99: interface ContextCategory { 100: name: string 101: tokens: number 102: color: keyof Theme 103: isDeferred?: boolean 104: } 105: interface GridSquare { 106: color: keyof Theme 107: isFilled: boolean 108: categoryName: string 109: tokens: number 110: percentage: number 111: squareFullness: number 112: } 113: interface MemoryFile { 114: path: string 115: type: string 116: tokens: number 117: } 118: interface McpTool { 119: name: string 120: serverName: string 121: tokens: number 122: isLoaded?: boolean 123: } 124: export interface DeferredBuiltinTool { 125: name: string 126: tokens: number 127: isLoaded: boolean 128: } 129: export interface SystemToolDetail { 130: name: string 131: tokens: number 132: } 133: export interface SystemPromptSectionDetail { 134: name: string 135: tokens: number 136: } 137: interface Agent { 138: agentType: string 139: source: SettingSource | 'built-in' | 'plugin' 140: tokens: number 141: } 142: interface SlashCommandInfo { 143: readonly totalCommands: number 144: readonly includedCommands: number 145: readonly tokens: number 146: } 147: interface SkillFrontmatter { 148: name: string 149: source: SettingSource | 'plugin' 150: tokens: number 151: } 152: interface SkillInfo { 153: readonly totalSkills: number 154: readonly includedSkills: number 155: readonly tokens: number 156: readonly skillFrontmatter: SkillFrontmatter[] 157: } 158: export interface ContextData { 159: readonly categories: ContextCategory[] 160: readonly totalTokens: number 161: readonly maxTokens: number 162: readonly rawMaxTokens: number 163: readonly percentage: number 164: readonly gridRows: GridSquare[][] 165: readonly model: string 166: readonly memoryFiles: MemoryFile[] 167: readonly mcpTools: McpTool[] 168: readonly deferredBuiltinTools?: DeferredBuiltinTool[] 169: readonly systemTools?: SystemToolDetail[] 170: readonly systemPromptSections?: SystemPromptSectionDetail[] 171: readonly agents: Agent[] 172: readonly slashCommands?: SlashCommandInfo 173: readonly skills?: SkillInfo 174: readonly autoCompactThreshold?: number 175: readonly isAutoCompactEnabled: boolean 176: messageBreakdown?: { 177: toolCallTokens: number 178: toolResultTokens: number 179: attachmentTokens: number 180: assistantMessageTokens: number 181: userMessageTokens: number 182: toolCallsByType: Array<{ 183: name: string 184: callTokens: number 185: resultTokens: number 186: }> 187: attachmentsByType: Array<{ name: string; tokens: number }> 188: } 189: readonly apiUsage: { 190: input_tokens: number 191: output_tokens: number 192: cache_creation_input_tokens: number 193: cache_read_input_tokens: number 194: } | null 195: } 196: export async function countToolDefinitionTokens( 197: tools: Tools, 198: getToolPermissionContext: () => Promise<ToolPermissionContext>, 199: agentInfo: AgentDefinitionsResult | null, 200: model?: string, 201: ): Promise<number> { 202: const toolSchemas = await Promise.all( 203: tools.map(tool => 204: toolToAPISchema(tool, { 205: getToolPermissionContext, 206: tools, 207: agents: agentInfo?.activeAgents ?? [], 208: model, 209: }), 210: ), 211: ) 212: const result = await countTokensWithFallback([], toolSchemas) 213: if (result === null || result === 0) { 214: const toolNames = tools.map(t => t.name).join(', ') 215: logForDebugging( 216: `countToolDefinitionTokens returned ${result} for ${tools.length} tools: ${toolNames.slice(0, 100)}${toolNames.length > 100 ? '...' : ''}`, 217: ) 218: } 219: return result ?? 0 220: } 221: function extractSectionName(content: string): string { 222: const headingMatch = content.match(/^#+\s+(.+)$/m) 223: if (headingMatch) { 224: return headingMatch[1]!.trim() 225: } 226: const firstLine = content.split('\n').find(l => l.trim().length > 0) ?? '' 227: return firstLine.length > 40 ? firstLine.slice(0, 40) + '…' : firstLine 228: } 229: async function countSystemTokens( 230: effectiveSystemPrompt: readonly string[], 231: ): Promise<{ 232: systemPromptTokens: number 233: systemPromptSections: SystemPromptSectionDetail[] 234: }> { 235: // Get system context (gitStatus, etc.) which is always included 236: const systemContext = await getSystemContext() 237: // Build named entries: system prompt parts + system context values 238: // Skip empty strings and the global-cache boundary marker 239: const namedEntries: Array<{ name: string; content: string }> = [ 240: ...effectiveSystemPrompt 241: .filter( 242: content => 243: content.length > 0 && content !== SYSTEM_PROMPT_DYNAMIC_BOUNDARY, 244: ) 245: .map(content => ({ name: extractSectionName(content), content })), 246: ...Object.entries(systemContext) 247: .filter(([, content]) => content.length > 0) 248: .map(([name, content]) => ({ name, content })), 249: ] 250: if (namedEntries.length < 1) { 251: return { systemPromptTokens: 0, systemPromptSections: [] } 252: } 253: const systemTokenCounts = await Promise.all( 254: namedEntries.map(({ content }) => 255: countTokensWithFallback([{ role: 'user', content }], []), 256: ), 257: ) 258: const systemPromptSections: SystemPromptSectionDetail[] = namedEntries.map( 259: (entry, i) => ({ 260: name: entry.name, 261: tokens: systemTokenCounts[i] || 0, 262: }), 263: ) 264: const systemPromptTokens = systemTokenCounts.reduce( 265: (sum: number, tokens) => sum + (tokens || 0), 266: 0, 267: ) 268: return { systemPromptTokens, systemPromptSections } 269: } 270: async function countMemoryFileTokens(): Promise<{ 271: memoryFileDetails: MemoryFile[] 272: claudeMdTokens: number 273: }> { 274: if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 275: return { memoryFileDetails: [], claudeMdTokens: 0 } 276: } 277: const memoryFilesData = filterInjectedMemoryFiles(await getMemoryFiles()) 278: const memoryFileDetails: MemoryFile[] = [] 279: let claudeMdTokens = 0 280: if (memoryFilesData.length < 1) { 281: return { 282: memoryFileDetails: [], 283: claudeMdTokens: 0, 284: } 285: } 286: const claudeMdTokenCounts = await Promise.all( 287: memoryFilesData.map(async file => { 288: const tokens = await countTokensWithFallback( 289: [{ role: 'user', content: file.content }], 290: [], 291: ) 292: return { file, tokens: tokens || 0 } 293: }), 294: ) 295: for (const { file, tokens } of claudeMdTokenCounts) { 296: claudeMdTokens += tokens 297: memoryFileDetails.push({ 298: path: file.path, 299: type: file.type, 300: tokens, 301: }) 302: } 303: return { claudeMdTokens, memoryFileDetails } 304: } 305: async function countBuiltInToolTokens( 306: tools: Tools, 307: getToolPermissionContext: () => Promise<ToolPermissionContext>, 308: agentInfo: AgentDefinitionsResult | null, 309: model?: string, 310: messages?: Message[], 311: ): Promise<{ 312: builtInToolTokens: number 313: deferredBuiltinDetails: DeferredBuiltinTool[] 314: deferredBuiltinTokens: number 315: systemToolDetails: SystemToolDetail[] 316: }> { 317: const builtInTools = tools.filter(tool => !tool.isMcp) 318: if (builtInTools.length < 1) { 319: return { 320: builtInToolTokens: 0, 321: deferredBuiltinDetails: [], 322: deferredBuiltinTokens: 0, 323: systemToolDetails: [], 324: } 325: } 326: const { isToolSearchEnabled } = await import('./toolSearch.js') 327: const { isDeferredTool } = await import('../tools/ToolSearchTool/prompt.js') 328: const isDeferred = await isToolSearchEnabled( 329: model ?? '', 330: tools, 331: getToolPermissionContext, 332: agentInfo?.activeAgents ?? [], 333: 'analyzeBuiltIn', 334: ) 335: const alwaysLoadedTools = builtInTools.filter(t => !isDeferredTool(t)) 336: const deferredBuiltinTools = builtInTools.filter(t => isDeferredTool(t)) 337: const alwaysLoadedTokens = 338: alwaysLoadedTools.length > 0 339: ? await countToolDefinitionTokens( 340: alwaysLoadedTools, 341: getToolPermissionContext, 342: agentInfo, 343: model, 344: ) 345: : 0 346: let systemToolDetails: SystemToolDetail[] = [] 347: if (process.env.USER_TYPE === 'ant') { 348: const toolsForBreakdown = alwaysLoadedTools.filter( 349: t => !toolMatchesName(t, SKILL_TOOL_NAME), 350: ) 351: if (toolsForBreakdown.length > 0) { 352: const estimates = toolsForBreakdown.map(t => 353: roughTokenCountEstimation(jsonStringify(t.inputSchema ?? {})), 354: ) 355: const estimateTotal = estimates.reduce((s, e) => s + e, 0) || 1 356: const distributable = Math.max( 357: 0, 358: alwaysLoadedTokens - TOOL_TOKEN_COUNT_OVERHEAD, 359: ) 360: systemToolDetails = toolsForBreakdown 361: .map((t, i) => ({ 362: name: t.name, 363: tokens: Math.round((estimates[i]! / estimateTotal) * distributable), 364: })) 365: .sort((a, b) => b.tokens - a.tokens) 366: } 367: } 368: const deferredBuiltinDetails: DeferredBuiltinTool[] = [] 369: let loadedDeferredTokens = 0 370: let totalDeferredTokens = 0 371: if (deferredBuiltinTools.length > 0 && isDeferred) { 372: const loadedToolNames = new Set<string>() 373: if (messages) { 374: const deferredToolNameSet = new Set(deferredBuiltinTools.map(t => t.name)) 375: for (const msg of messages) { 376: if (msg.type === 'assistant') { 377: for (const block of msg.message.content) { 378: if ( 379: 'type' in block && 380: block.type === 'tool_use' && 381: 'name' in block && 382: typeof block.name === 'string' && 383: deferredToolNameSet.has(block.name) 384: ) { 385: loadedToolNames.add(block.name) 386: } 387: } 388: } 389: } 390: } 391: const tokensByTool = await Promise.all( 392: deferredBuiltinTools.map(t => 393: countToolDefinitionTokens( 394: [t], 395: getToolPermissionContext, 396: agentInfo, 397: model, 398: ), 399: ), 400: ) 401: for (const [i, tool] of deferredBuiltinTools.entries()) { 402: const tokens = Math.max( 403: 0, 404: (tokensByTool[i] || 0) - TOOL_TOKEN_COUNT_OVERHEAD, 405: ) 406: const isLoaded = loadedToolNames.has(tool.name) 407: deferredBuiltinDetails.push({ 408: name: tool.name, 409: tokens, 410: isLoaded, 411: }) 412: totalDeferredTokens += tokens 413: if (isLoaded) { 414: loadedDeferredTokens += tokens 415: } 416: } 417: } else if (deferredBuiltinTools.length > 0) { 418: const deferredTokens = await countToolDefinitionTokens( 419: deferredBuiltinTools, 420: getToolPermissionContext, 421: agentInfo, 422: model, 423: ) 424: return { 425: builtInToolTokens: alwaysLoadedTokens + deferredTokens, 426: deferredBuiltinDetails: [], 427: deferredBuiltinTokens: 0, 428: systemToolDetails, 429: } 430: } 431: return { 432: builtInToolTokens: alwaysLoadedTokens + loadedDeferredTokens, 433: deferredBuiltinDetails, 434: deferredBuiltinTokens: totalDeferredTokens - loadedDeferredTokens, 435: systemToolDetails, 436: } 437: } 438: function findSkillTool(tools: Tools): Tool | undefined { 439: return findToolByName(tools, SKILL_TOOL_NAME) 440: } 441: async function countSlashCommandTokens( 442: tools: Tools, 443: getToolPermissionContext: () => Promise<ToolPermissionContext>, 444: agentInfo: AgentDefinitionsResult | null, 445: ): Promise<{ 446: slashCommandTokens: number 447: commandInfo: { totalCommands: number; includedCommands: number } 448: }> { 449: const info = await getSlashCommandInfo(getCwd()) 450: const slashCommandTool = findSkillTool(tools) 451: if (!slashCommandTool) { 452: return { 453: slashCommandTokens: 0, 454: commandInfo: { totalCommands: 0, includedCommands: 0 }, 455: } 456: } 457: const slashCommandTokens = await countToolDefinitionTokens( 458: [slashCommandTool], 459: getToolPermissionContext, 460: agentInfo, 461: ) 462: return { 463: slashCommandTokens, 464: commandInfo: { 465: totalCommands: info.totalCommands, 466: includedCommands: info.includedCommands, 467: }, 468: } 469: } 470: async function countSkillTokens( 471: tools: Tools, 472: getToolPermissionContext: () => Promise<ToolPermissionContext>, 473: agentInfo: AgentDefinitionsResult | null, 474: ): Promise<{ 475: skillTokens: number 476: skillInfo: { 477: totalSkills: number 478: includedSkills: number 479: skillFrontmatter: SkillFrontmatter[] 480: } 481: }> { 482: try { 483: const skills = await getLimitedSkillToolCommands(getCwd()) 484: const slashCommandTool = findSkillTool(tools) 485: if (!slashCommandTool) { 486: return { 487: skillTokens: 0, 488: skillInfo: { totalSkills: 0, includedSkills: 0, skillFrontmatter: [] }, 489: } 490: } 491: const skillTokens = await countToolDefinitionTokens( 492: [slashCommandTool], 493: getToolPermissionContext, 494: agentInfo, 495: ) 496: const skillFrontmatter: SkillFrontmatter[] = skills.map(skill => ({ 497: name: getCommandName(skill), 498: source: (skill.type === 'prompt' ? skill.source : 'plugin') as 499: | SettingSource 500: | 'plugin', 501: tokens: estimateSkillFrontmatterTokens(skill), 502: })) 503: return { 504: skillTokens, 505: skillInfo: { 506: totalSkills: skills.length, 507: includedSkills: skills.length, 508: skillFrontmatter, 509: }, 510: } 511: } catch (error) { 512: logError(toError(error)) 513: return { 514: skillTokens: 0, 515: skillInfo: { totalSkills: 0, includedSkills: 0, skillFrontmatter: [] }, 516: } 517: } 518: } 519: export async function countMcpToolTokens( 520: tools: Tools, 521: getToolPermissionContext: () => Promise<ToolPermissionContext>, 522: agentInfo: AgentDefinitionsResult | null, 523: model: string, 524: messages?: Message[], 525: ): Promise<{ 526: mcpToolTokens: number 527: mcpToolDetails: McpTool[] 528: deferredToolTokens: number 529: loadedMcpToolNames: Set<string> 530: }> { 531: const mcpTools = tools.filter(tool => tool.isMcp) 532: const mcpToolDetails: McpTool[] = [] 533: const totalTokensRaw = await countToolDefinitionTokens( 534: mcpTools, 535: getToolPermissionContext, 536: agentInfo, 537: model, 538: ) 539: const totalTokens = Math.max( 540: 0, 541: (totalTokensRaw || 0) - TOOL_TOKEN_COUNT_OVERHEAD, 542: ) 543: const estimates = await Promise.all( 544: mcpTools.map(async t => 545: roughTokenCountEstimation( 546: jsonStringify({ 547: name: t.name, 548: description: await t.prompt({ 549: getToolPermissionContext, 550: tools, 551: agents: agentInfo?.activeAgents ?? [], 552: }), 553: input_schema: t.inputJSONSchema ?? {}, 554: }), 555: ), 556: ), 557: ) 558: const estimateTotal = estimates.reduce((s, e) => s + e, 0) || 1 559: const mcpToolTokensByTool = estimates.map(e => 560: Math.round((e / estimateTotal) * totalTokens), 561: ) 562: const { isToolSearchEnabled } = await import('./toolSearch.js') 563: const { isDeferredTool } = await import('../tools/ToolSearchTool/prompt.js') 564: const isDeferred = await isToolSearchEnabled( 565: model, 566: tools, 567: getToolPermissionContext, 568: agentInfo?.activeAgents ?? [], 569: 'analyzeMcp', 570: ) 571: const loadedMcpToolNames = new Set<string>() 572: if (isDeferred && messages) { 573: const mcpToolNameSet = new Set(mcpTools.map(t => t.name)) 574: for (const msg of messages) { 575: if (msg.type === 'assistant') { 576: for (const block of msg.message.content) { 577: if ( 578: 'type' in block && 579: block.type === 'tool_use' && 580: 'name' in block && 581: typeof block.name === 'string' && 582: mcpToolNameSet.has(block.name) 583: ) { 584: loadedMcpToolNames.add(block.name) 585: } 586: } 587: } 588: } 589: } 590: for (const [i, tool] of mcpTools.entries()) { 591: mcpToolDetails.push({ 592: name: tool.name, 593: serverName: tool.name.split('__')[1] || 'unknown', 594: tokens: mcpToolTokensByTool[i]!, 595: isLoaded: loadedMcpToolNames.has(tool.name) || !isDeferredTool(tool), 596: }) 597: } 598: let loadedTokens = 0 599: let deferredTokens = 0 600: for (const detail of mcpToolDetails) { 601: if (detail.isLoaded) { 602: loadedTokens += detail.tokens 603: } else if (isDeferred) { 604: deferredTokens += detail.tokens 605: } 606: } 607: return { 608: mcpToolTokens: isDeferred ? loadedTokens : totalTokens, 609: mcpToolDetails, 610: deferredToolTokens: deferredTokens, 611: loadedMcpToolNames, 612: } 613: } 614: async function countCustomAgentTokens(agentDefinitions: { 615: activeAgents: AgentDefinition[] 616: }): Promise<{ 617: agentTokens: number 618: agentDetails: Agent[] 619: }> { 620: const customAgents = agentDefinitions.activeAgents.filter( 621: a => a.source !== 'built-in', 622: ) 623: const agentDetails: Agent[] = [] 624: let agentTokens = 0 625: const tokenCounts = await Promise.all( 626: customAgents.map(agent => 627: countTokensWithFallback( 628: [ 629: { 630: role: 'user', 631: content: [agent.agentType, agent.whenToUse].join(' '), 632: }, 633: ], 634: [], 635: ), 636: ), 637: ) 638: for (const [i, agent] of customAgents.entries()) { 639: const tokens = tokenCounts[i] || 0 640: agentTokens += tokens || 0 641: agentDetails.push({ 642: agentType: agent.agentType, 643: source: agent.source, 644: tokens: tokens || 0, 645: }) 646: } 647: return { agentTokens, agentDetails } 648: } 649: type MessageBreakdown = { 650: totalTokens: number 651: toolCallTokens: number 652: toolResultTokens: number 653: attachmentTokens: number 654: assistantMessageTokens: number 655: userMessageTokens: number 656: toolCallsByType: Map<string, number> 657: toolResultsByType: Map<string, number> 658: attachmentsByType: Map<string, number> 659: } 660: function processAssistantMessage( 661: msg: AssistantMessage | NormalizedAssistantMessage, 662: breakdown: MessageBreakdown, 663: ): void { 664: for (const block of msg.message.content) { 665: const blockStr = jsonStringify(block) 666: const blockTokens = roughTokenCountEstimation(blockStr) 667: if ('type' in block && block.type === 'tool_use') { 668: breakdown.toolCallTokens += blockTokens 669: const toolName = ('name' in block ? block.name : undefined) || 'unknown' 670: breakdown.toolCallsByType.set( 671: toolName, 672: (breakdown.toolCallsByType.get(toolName) || 0) + blockTokens, 673: ) 674: } else { 675: breakdown.assistantMessageTokens += blockTokens 676: } 677: } 678: } 679: function processUserMessage( 680: msg: UserMessage | NormalizedUserMessage, 681: breakdown: MessageBreakdown, 682: toolUseIdToName: Map<string, string>, 683: ): void { 684: if (typeof msg.message.content === 'string') { 685: const tokens = roughTokenCountEstimation(msg.message.content) 686: breakdown.userMessageTokens += tokens 687: return 688: } 689: for (const block of msg.message.content) { 690: const blockStr = jsonStringify(block) 691: const blockTokens = roughTokenCountEstimation(blockStr) 692: if ('type' in block && block.type === 'tool_result') { 693: breakdown.toolResultTokens += blockTokens 694: const toolUseId = 'tool_use_id' in block ? block.tool_use_id : undefined 695: const toolName = 696: (toolUseId ? toolUseIdToName.get(toolUseId) : undefined) || 'unknown' 697: breakdown.toolResultsByType.set( 698: toolName, 699: (breakdown.toolResultsByType.get(toolName) || 0) + blockTokens, 700: ) 701: } else { 702: breakdown.userMessageTokens += blockTokens 703: } 704: } 705: } 706: function processAttachment( 707: msg: AttachmentMessage, 708: breakdown: MessageBreakdown, 709: ): void { 710: const contentStr = jsonStringify(msg.attachment) 711: const tokens = roughTokenCountEstimation(contentStr) 712: breakdown.attachmentTokens += tokens 713: const attachType = msg.attachment.type || 'unknown' 714: breakdown.attachmentsByType.set( 715: attachType, 716: (breakdown.attachmentsByType.get(attachType) || 0) + tokens, 717: ) 718: } 719: async function approximateMessageTokens( 720: messages: Message[], 721: ): Promise<MessageBreakdown> { 722: const microcompactResult = await microcompactMessages(messages) 723: const breakdown: MessageBreakdown = { 724: totalTokens: 0, 725: toolCallTokens: 0, 726: toolResultTokens: 0, 727: attachmentTokens: 0, 728: assistantMessageTokens: 0, 729: userMessageTokens: 0, 730: toolCallsByType: new Map<string, number>(), 731: toolResultsByType: new Map<string, number>(), 732: attachmentsByType: new Map<string, number>(), 733: } 734: const toolUseIdToName = new Map<string, string>() 735: for (const msg of microcompactResult.messages) { 736: if (msg.type === 'assistant') { 737: for (const block of msg.message.content) { 738: if ('type' in block && block.type === 'tool_use') { 739: const toolUseId = 'id' in block ? block.id : undefined 740: const toolName = 741: ('name' in block ? block.name : undefined) || 'unknown' 742: if (toolUseId) { 743: toolUseIdToName.set(toolUseId, toolName) 744: } 745: } 746: } 747: } 748: } 749: for (const msg of microcompactResult.messages) { 750: if (msg.type === 'assistant') { 751: processAssistantMessage(msg, breakdown) 752: } else if (msg.type === 'user') { 753: processUserMessage(msg, breakdown, toolUseIdToName) 754: } else if (msg.type === 'attachment') { 755: processAttachment(msg, breakdown) 756: } 757: } 758: const approximateMessageTokens = await countTokensWithFallback( 759: normalizeMessagesForAPI(microcompactResult.messages).map(_ => { 760: if (_.type === 'assistant') { 761: return { 762: role: 'assistant', 763: content: _.message.content, 764: } 765: } 766: return _.message 767: }), 768: [], 769: ) 770: breakdown.totalTokens = approximateMessageTokens ?? 0 771: return breakdown 772: } 773: export async function analyzeContextUsage( 774: messages: Message[], 775: model: string, 776: getToolPermissionContext: () => Promise<ToolPermissionContext>, 777: tools: Tools, 778: agentDefinitions: AgentDefinitionsResult, 779: terminalWidth?: number, 780: toolUseContext?: Pick<ToolUseContext, 'options'>, 781: mainThreadAgentDefinition?: AgentDefinition, 782: originalMessages?: Message[], 783: ): Promise<ContextData> { 784: const runtimeModel = getRuntimeMainLoopModel({ 785: permissionMode: (await getToolPermissionContext()).mode, 786: mainLoopModel: model, 787: }) 788: const contextWindow = getContextWindowForModel(runtimeModel, getSdkBetas()) 789: const defaultSystemPrompt = await getSystemPrompt(tools, runtimeModel) 790: const effectiveSystemPrompt = buildEffectiveSystemPrompt({ 791: mainThreadAgentDefinition, 792: toolUseContext: toolUseContext ?? { 793: options: {} as ToolUseContext['options'], 794: }, 795: customSystemPrompt: toolUseContext?.options.customSystemPrompt, 796: defaultSystemPrompt, 797: appendSystemPrompt: toolUseContext?.options.appendSystemPrompt, 798: }) 799: const [ 800: { systemPromptTokens, systemPromptSections }, 801: { claudeMdTokens, memoryFileDetails }, 802: { 803: builtInToolTokens, 804: deferredBuiltinDetails, 805: deferredBuiltinTokens, 806: systemToolDetails, 807: }, 808: { mcpToolTokens, mcpToolDetails, deferredToolTokens }, 809: { agentTokens, agentDetails }, 810: { slashCommandTokens, commandInfo }, 811: messageBreakdown, 812: ] = await Promise.all([ 813: countSystemTokens(effectiveSystemPrompt), 814: countMemoryFileTokens(), 815: countBuiltInToolTokens( 816: tools, 817: getToolPermissionContext, 818: agentDefinitions, 819: runtimeModel, 820: messages, 821: ), 822: countMcpToolTokens( 823: tools, 824: getToolPermissionContext, 825: agentDefinitions, 826: runtimeModel, 827: messages, 828: ), 829: countCustomAgentTokens(agentDefinitions), 830: countSlashCommandTokens(tools, getToolPermissionContext, agentDefinitions), 831: approximateMessageTokens(messages), 832: ]) 833: const skillResult = await countSkillTokens( 834: tools, 835: getToolPermissionContext, 836: agentDefinitions, 837: ) 838: const skillInfo = skillResult.skillInfo 839: const skillFrontmatterTokens = skillInfo.skillFrontmatter.reduce( 840: (sum, skill) => sum + skill.tokens, 841: 0, 842: ) 843: const messageTokens = messageBreakdown.totalTokens 844: const isAutoCompact = isAutoCompactEnabled() 845: const autoCompactThreshold = isAutoCompact 846: ? getEffectiveContextWindowSize(model) - AUTOCOMPACT_BUFFER_TOKENS 847: : undefined 848: const cats: ContextCategory[] = [] 849: if (systemPromptTokens > 0) { 850: cats.push({ 851: name: 'System prompt', 852: tokens: systemPromptTokens, 853: color: 'promptBorder', 854: }) 855: } 856: const systemToolsTokens = builtInToolTokens - skillFrontmatterTokens 857: if (systemToolsTokens > 0) { 858: cats.push({ 859: name: 860: process.env.USER_TYPE === 'ant' 861: ? '[ANT-ONLY] System tools' 862: : 'System tools', 863: tokens: systemToolsTokens, 864: color: 'inactive', 865: }) 866: } 867: if (mcpToolTokens > 0) { 868: cats.push({ 869: name: 'MCP tools', 870: tokens: mcpToolTokens, 871: color: 'cyan_FOR_SUBAGENTS_ONLY', 872: }) 873: } 874: if (deferredToolTokens > 0) { 875: cats.push({ 876: name: 'MCP tools (deferred)', 877: tokens: deferredToolTokens, 878: color: 'inactive', 879: isDeferred: true, 880: }) 881: } 882: if (deferredBuiltinTokens > 0) { 883: cats.push({ 884: name: 'System tools (deferred)', 885: tokens: deferredBuiltinTokens, 886: color: 'inactive', 887: isDeferred: true, 888: }) 889: } 890: if (agentTokens > 0) { 891: cats.push({ 892: name: 'Custom agents', 893: tokens: agentTokens, 894: color: 'permission', 895: }) 896: } 897: if (claudeMdTokens > 0) { 898: cats.push({ 899: name: 'Memory files', 900: tokens: claudeMdTokens, 901: color: 'claude', 902: }) 903: } 904: if (skillFrontmatterTokens > 0) { 905: cats.push({ 906: name: 'Skills', 907: tokens: skillFrontmatterTokens, 908: color: 'warning', 909: }) 910: } 911: if (messageTokens !== null && messageTokens > 0) { 912: cats.push({ 913: name: 'Messages', 914: tokens: messageTokens, 915: color: 'purple_FOR_SUBAGENTS_ONLY', 916: }) 917: } 918: const actualUsage = cats.reduce( 919: (sum, cat) => sum + (cat.isDeferred ? 0 : cat.tokens), 920: 0, 921: ) 922: let reservedTokens = 0 923: let skipReservedBuffer = false 924: if (feature('REACTIVE_COMPACT')) { 925: if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) { 926: skipReservedBuffer = true 927: } 928: } 929: if (feature('CONTEXT_COLLAPSE')) { 930: const { isContextCollapseEnabled } = 931: require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') 932: if (isContextCollapseEnabled()) { 933: skipReservedBuffer = true 934: } 935: } 936: if (skipReservedBuffer) { 937: } else if (isAutoCompact && autoCompactThreshold !== undefined) { 938: reservedTokens = contextWindow - autoCompactThreshold 939: cats.push({ 940: name: RESERVED_CATEGORY_NAME, 941: tokens: reservedTokens, 942: color: 'inactive', 943: }) 944: } else if (!isAutoCompact) { 945: reservedTokens = MANUAL_COMPACT_BUFFER_TOKENS 946: cats.push({ 947: name: MANUAL_COMPACT_BUFFER_NAME, 948: tokens: reservedTokens, 949: color: 'inactive', 950: }) 951: } 952: const freeTokens = Math.max(0, contextWindow - actualUsage - reservedTokens) 953: cats.push({ 954: name: 'Free space', 955: tokens: freeTokens, 956: color: 'promptBorder', 957: }) 958: const totalIncludingReserved = actualUsage 959: const apiUsage = getCurrentUsage(originalMessages ?? messages) 960: const totalFromAPI = apiUsage 961: ? apiUsage.input_tokens + 962: apiUsage.cache_creation_input_tokens + 963: apiUsage.cache_read_input_tokens 964: : null 965: const finalTotalTokens = totalFromAPI ?? totalIncludingReserved 966: const isNarrowScreen = terminalWidth && terminalWidth < 80 967: const GRID_WIDTH = 968: contextWindow >= 1000000 969: ? isNarrowScreen 970: ? 5 971: : 20 972: : isNarrowScreen 973: ? 5 974: : 10 975: const GRID_HEIGHT = contextWindow >= 1000000 ? 10 : isNarrowScreen ? 5 : 10 976: const TOTAL_SQUARES = GRID_WIDTH * GRID_HEIGHT 977: const nonDeferredCats = cats.filter(cat => !cat.isDeferred) 978: const categorySquares = nonDeferredCats.map(cat => ({ 979: ...cat, 980: squares: 981: cat.name === 'Free space' 982: ? Math.round((cat.tokens / contextWindow) * TOTAL_SQUARES) 983: : Math.max(1, Math.round((cat.tokens / contextWindow) * TOTAL_SQUARES)), 984: percentageOfTotal: Math.round((cat.tokens / contextWindow) * 100), 985: })) 986: function createCategorySquares( 987: category: (typeof categorySquares)[0], 988: ): GridSquare[] { 989: const squares: GridSquare[] = [] 990: const exactSquares = (category.tokens / contextWindow) * TOTAL_SQUARES 991: const wholeSquares = Math.floor(exactSquares) 992: const fractionalPart = exactSquares - wholeSquares 993: for (let i = 0; i < category.squares; i++) { 994: let squareFullness = 1.0 995: if (i === wholeSquares && fractionalPart > 0) { 996: squareFullness = fractionalPart 997: } 998: squares.push({ 999: color: category.color, 1000: isFilled: true, 1001: categoryName: category.name, 1002: tokens: category.tokens, 1003: percentage: category.percentageOfTotal, 1004: squareFullness, 1005: }) 1006: } 1007: return squares 1008: } 1009: const gridSquares: GridSquare[] = [] 1010: const reservedCategory = categorySquares.find( 1011: cat => 1012: cat.name === RESERVED_CATEGORY_NAME || 1013: cat.name === MANUAL_COMPACT_BUFFER_NAME, 1014: ) 1015: const nonReservedCategories = categorySquares.filter( 1016: cat => 1017: cat.name !== RESERVED_CATEGORY_NAME && 1018: cat.name !== MANUAL_COMPACT_BUFFER_NAME && 1019: cat.name !== 'Free space', 1020: ) 1021: for (const cat of nonReservedCategories) { 1022: const squares = createCategorySquares(cat) 1023: for (const square of squares) { 1024: if (gridSquares.length < TOTAL_SQUARES) { 1025: gridSquares.push(square) 1026: } 1027: } 1028: } 1029: const reservedSquareCount = reservedCategory ? reservedCategory.squares : 0 1030: const freeSpaceCat = cats.find(c => c.name === 'Free space') 1031: const freeSpaceTarget = TOTAL_SQUARES - reservedSquareCount 1032: while (gridSquares.length < freeSpaceTarget) { 1033: gridSquares.push({ 1034: color: 'promptBorder', 1035: isFilled: true, 1036: categoryName: 'Free space', 1037: tokens: freeSpaceCat?.tokens || 0, 1038: percentage: freeSpaceCat 1039: ? Math.round((freeSpaceCat.tokens / contextWindow) * 100) 1040: : 0, 1041: squareFullness: 1.0, 1042: }) 1043: } 1044: if (reservedCategory) { 1045: const squares = createCategorySquares(reservedCategory) 1046: for (const square of squares) { 1047: if (gridSquares.length < TOTAL_SQUARES) { 1048: gridSquares.push(square) 1049: } 1050: } 1051: } 1052: const gridRows: GridSquare[][] = [] 1053: for (let i = 0; i < GRID_HEIGHT; i++) { 1054: gridRows.push(gridSquares.slice(i * GRID_WIDTH, (i + 1) * GRID_WIDTH)) 1055: } 1056: const toolsMap = new Map< 1057: string, 1058: { callTokens: number; resultTokens: number } 1059: >() 1060: for (const [name, tokens] of messageBreakdown.toolCallsByType.entries()) { 1061: const existing = toolsMap.get(name) || { callTokens: 0, resultTokens: 0 } 1062: toolsMap.set(name, { ...existing, callTokens: tokens }) 1063: } 1064: for (const [name, tokens] of messageBreakdown.toolResultsByType.entries()) { 1065: const existing = toolsMap.get(name) || { callTokens: 0, resultTokens: 0 } 1066: toolsMap.set(name, { ...existing, resultTokens: tokens }) 1067: } 1068: const toolsByTypeArray = Array.from(toolsMap.entries()) 1069: .map(([name, { callTokens, resultTokens }]) => ({ 1070: name, 1071: callTokens, 1072: resultTokens, 1073: })) 1074: .sort( 1075: (a, b) => b.callTokens + b.resultTokens - (a.callTokens + a.resultTokens), 1076: ) 1077: const attachmentsByTypeArray = Array.from( 1078: messageBreakdown.attachmentsByType.entries(), 1079: ) 1080: .map(([name, tokens]) => ({ name, tokens })) 1081: .sort((a, b) => b.tokens - a.tokens) 1082: const formattedMessageBreakdown = { 1083: toolCallTokens: messageBreakdown.toolCallTokens, 1084: toolResultTokens: messageBreakdown.toolResultTokens, 1085: attachmentTokens: messageBreakdown.attachmentTokens, 1086: assistantMessageTokens: messageBreakdown.assistantMessageTokens, 1087: userMessageTokens: messageBreakdown.userMessageTokens, 1088: toolCallsByType: toolsByTypeArray, 1089: attachmentsByType: attachmentsByTypeArray, 1090: } 1091: return { 1092: categories: cats, 1093: totalTokens: finalTotalTokens, 1094: maxTokens: contextWindow, 1095: rawMaxTokens: contextWindow, 1096: percentage: Math.round((finalTotalTokens / contextWindow) * 100), 1097: gridRows, 1098: model: runtimeModel, 1099: memoryFiles: memoryFileDetails, 1100: mcpTools: mcpToolDetails, 1101: deferredBuiltinTools: 1102: process.env.USER_TYPE === 'ant' ? deferredBuiltinDetails : undefined, 1103: systemTools: 1104: process.env.USER_TYPE === 'ant' ? systemToolDetails : undefined, 1105: systemPromptSections: 1106: process.env.USER_TYPE === 'ant' ? systemPromptSections : undefined, 1107: agents: agentDetails, 1108: slashCommands: 1109: slashCommandTokens > 0 1110: ? { 1111: totalCommands: commandInfo.totalCommands, 1112: includedCommands: commandInfo.includedCommands, 1113: tokens: slashCommandTokens, 1114: } 1115: : undefined, 1116: skills: 1117: skillFrontmatterTokens > 0 1118: ? { 1119: totalSkills: skillInfo.totalSkills, 1120: includedSkills: skillInfo.includedSkills, 1121: tokens: skillFrontmatterTokens, 1122: skillFrontmatter: skillInfo.skillFrontmatter, 1123: } 1124: : undefined, 1125: autoCompactThreshold, 1126: isAutoCompactEnabled: isAutoCompact, 1127: messageBreakdown: formattedMessageBreakdown, 1128: apiUsage, 1129: } 1130: }

File: src/utils/ansiToPng.ts

typescript 1: import { deflateSync } from 'zlib' 2: import { stringWidth } from '../ink/stringWidth.js' 3: import { 4: type AnsiColor, 5: DEFAULT_BG, 6: type ParsedLine, 7: parseAnsi, 8: } from './ansiToSvg.js' 9: const GLYPH_W = 24 10: const GLYPH_H = 48 11: const GLYPH_BYTES = GLYPH_W * GLYPH_H 12: const FONT_B64 = 13: 'hQAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwQEBAEAAAAAAAAAAAAAAAAAAAAAAAAAC/////EAAAAAAAAAAAAAAAAAAAAAAAAAC/////AAAAAAAAAAAAAAAAAAAAAAAAAAC/////AAAAAAAAAAAAAAAAAAAAAAAAAAC/////AAAAAAAAAAAAAAAAAAAAAAAAAACP////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA///vAAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAABw//+/AAAAAAAAAAAAAAAAAAAAAAAAAABA//+/AAAAAAAAAAAAAAAAAAAAAAAAAABA//+/AAAAAAAAAAAAAAAAAAAAAAAAAAAwv7+PAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABg7/+/EAAAAAAAAAAAAAAAAAAAAAAAADD/////vwAAAAAAAAAAAAAAAAAAAAAAAID//////wAAAAAAAAAAAAAAAAAAAAAAAGD/////7wAAAAAAAAAAAAAAAAAAAAAAAADP////YAAAAAAAAAAAAAAAAAAAAAAAAAAAYIAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQEBAQAAAIEBAQEAAAAAAAAAAAAAAAABA/////wAAUP///88AAAAAAAAAAAAAAABA/////wAAQP///78AAAAAAAAAAAAAAAAg////3wAAQP///78AAAAAAAAAAAAAAAAA////vwAAQP///78AAAAAAAAAAAAAAAAA////vwAAIP///48AAAAAAAAAAAAAAAAA////vwAAAP///4AAAAAAAAAAAAAAAAAA3///nwAAAP///4AAAAAAAAAAAAAAAAAAv///gAAAAP///4AAAAAAAAAAAAAAAAAAv///gAAAAO///1AAAAAAAAAAAAAAAAAAv///gAAAAL///0AAAAAAAAAAAAAAAAAAMEBAIAAAADBAQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAjAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwQEAQAAAAAAAwQEAAAAAAAAAAAAAAAADP//8gAAAAAAD///8AAAAAAAAAAAAAAAD///8AAAAAAAD//98AAAAAAAAAAAAAABD//88AAAAAAED//78AAAAAAAAAAAAAAED//78AAAAAAED//48AAAAAAAAAAAAAAGD//4AAAAAAAID//4AAAAAAAAAAAAAAAID//3AAAAAAAI///0AAAAAAAAAAIICAgL///5+AgICAgN///5+AgEAAAAAAQP///////////////////////4AAAAAAQP///////////////////////4AAAAAAEEBAQP//30BAQEBAYP//z0BAQCAAAAAAAAAAMP//vwAAAAAAQP//rwAAAAAAAAAAAAAAQP//nwAAAAAAYP//gAAAAAAAAAAAAAAAcP//gAAAAAAAgP//YAAAAAAAAAAAAAAAgP//UAAAAAAAr///QAAAAAAAAAAAAAAAv///QAAAAAAAv///IAAAAAAAAAAAAAAAz///EAAAAAAA////AAAAAAAAAAAAAAAA////AAAAAAAQ///PAAAAAAAAAAAAAAAg//+/AAAAAABA//+/AAAAAAAAAABggICf///fgICAgICf///PgICAAAAAAAC/////////////////////////AAAAAAC/////////////////////////AAAAAAAAAACv//9AAAAAAAC///8wAAAAAAAAAAAAAAC///8wAAAAAADf//8AAAAAAAAAAAAAAADv//8AAAAAAAD//+8AAAAAAAAAAAAAAAD//+8AAAAAACD//78AAAAAAAAAAAAAAED//78AAAAAAED//68AAAAAAAAAAAAAAED//58AAAAAAHD//4AAAAAAAAAAAAAAAID//4AAAAAAAID//3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYL+/MAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAFCAz///n3AwAAAAAAAAAAAAAAAAABCA7///////////34AQAAAAAAAAAAAAEM/////////////////fUAAAAAAAAAAAz////++Pn///cIDf/////3AAAAAAAABg////rxAAgP//QAAAQN//zxAAAAAAAADP///vEAAAgP//QAAAABCfEAAAAAAAAAD///+fAAAAgP//QAAAAAAAAAAAAAAAAAD///+AAAAAgP//QAAAAAAAAAAAAAAAAAD///+/AAAAgP//QAAAAAAAAAAAAAAAAADP////MAAAgP//QAAAAAAAAAAAAAAAAABg////70AAgP//QAAAAAAAAAAAAAAAAAAAn/////+/r///QAAAAAAAAAAAAAAAAAAAAJ//////////cAAAAAAAAAAAAAAAAAAAAABQ3////////++AEAAAAAAAAAAAAAAAAAAAEGDf////////73AAAAAAAAAAAAAAAAAAAAAAj/////////+fAAAAAAAAAAAAAAAAAAAAgP//gL//////jwAAAAAAAAAAAAAAAAAAgP//QABw/////0AAAAAAAAAAAAAAAAAAgP//QAAAcP///58AAAAAAAAAAAAAAAAAgP//QAAAAO///+8AAAAAAAAAAAAAAAAAgP//QAAAAL////8AAAAAAAAAAAAAAAAAgP//QAAAAL////8AAAAAAAAAAAAAAAAAgP//QAAAAL////8AAAAAAABgMAAAAAAAgP//QAAAEP///68AAAAAADDv71AAAAAAgP//QAAAn////2AAAAAAAN////+vIAAAgP//QCCv////zwAAAAAAADDf/////8+Pv///z//////vIAAAAAAAAAAQj////////////////88gAAAAAAAAAAAAACCf7//////////PYAAAAAAAAAAAAAAAAAAAADBQv///cBAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//QAAAAAAAAAAAAAAAAAAAAAAAAAAAIEBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQI+/r4AgAAAAAAAAAAAAAK+AAAAAABC/////////cAAAAAAAAAAAUP//nwAAAM////+/3////3AAAAAAAAAQ7///MAAAgP//7zAAAGD//+8QAAAAAACv//+AAAAA3///YAAAAACv//9wAAAAAGD//88AAAAg////AAAAAACA//+/AAAAIO//7zAAAABA////AAAAAABA//+/AAAAv///cAAAAABA////AAAAAABQ//+/AABw//+/AAAAAAAQ////EAAAAACA//+vACDv/+8gAAAAAAAAz///gAAAAADP//9gAL///3AAAAAAAAAAUP//71AAEJ///98AgP//rwAAAAAAAAAAAJ///////////0Aw///vEAAAAAAAAAAAAACA///////fQADP//9QAAAAAAAAAAAAAAAAEGCAgEAAAID//68AAAAAAAAAAAAAAAAAAAAAAAAAMP//7xAAAAAAAAAAAAAAAAAAAAAAAAAQz///QAAAAAAAAAAAAAAAAAAAAAAAAACP//+PABCAz///v2AAAAAAAAAAAAAAAED//98QMO/////////PEAAAAAAAAAAAEN///0AQ3///34+P7///rwAAAAAAAAAAj///jwCA///PEAAAMO///0AAAAAAAABA///PAADf//9QAAAAAI///58AAAAAABDv//8wABD///8AAAAAAFD//78AAAAAAK///4AAAED///8AAAAAAED///8AAAAAUP//zwAAACD///8AAAAAAED//88AAAAQ7//vMAAAAADv//9AAAAAAID//68AAACv//9wAAAAAACf//+vAAAAAN///2AAAHD//78AAAAAAAAg7///r0BAv///zwAAIO//7yAAAAAAAAAAYP/////////vMAAAYP//cAAAAAAAAAAAAEC//////68gAAAAADCAAAAAAAAAAAAAAAAAIEBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwgI+/j3AwAAAAAAAAAAAAAAAAAAAAQM//////////z0AAAAAAAAAAAAAAAABg//////////////9gAAAAAAAAAAAAADD////PQAAAAFC////vEAAAAAAAAAAAAL///88QAAAAAAAAn+8wAAAAAAAAAAAAIP///1AAAAAAAAAAACAAAAAAAAAAAAAAQP///xAAAAAAAAAAAAAAAAAAAAAAAAAAQP///xAAAAAAAAAAAAAAAAAAAAAAAAAAIP///1AAAAAAAAAAAAAAAAAAAAAAAAAAAN///88AAAAAAAAAAAAAAAAAAAAAAAAAAFD///+fEAAAAAAAAAAAAAAAAAAAAAAAAACP////33BAQEBAQEBAQEBAQEAgAAAAAAAAQK////////////////////+AAAAAAAAAII/P//////////////////+AAAAAABCf////z4+AgICAgJ///9+AgIBAAAAAEM///+9AAAAAAAAAAED//78AAAAAAAAAn///7zAAAAAAAAAAAED//78AAAAAAAAg////cAAAAAAAAAAAAED//78AAAAAAACA////EAAAAAAAAAAAAED//78AAAAAAAC///+/AAAAAAAAAAAAAED//78AAAAAAAC///+AAAAAAAAAAAAAAED//78AAAAAAAC///+PAAAAAAAAAAAAAED//78AAAAAAACv//+/AAAAAAAAAAAAAED//78AAAAAAABw////IAAAAAAAAAAAAED//78AAAAAAAAg////rwAAAAAAAAAAAGD//78AAAAAAAAAn////58AAAAAAAAAcO///78AAAAAAAAAEM/////fj2BAYI/f////7zAAAAAAAAAAABDP///////////////PIAAAAAAAAAAAAAAAgN//////////z2AAAAAAAAAAAAAAAAAAAAAwUICAgEAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwQEBAIAAAAAAAAAAAAAAAAAAAAAAAAAC/////gAAAAAAAAAAAAAAAAAAAAAAAAAC/////UAAAAAAAAAAAAAAAAAAAAAAAAAC/////QAAAAAAAAAAAAAAAAAAAAAAAAACf////QAAAAAAAAAAAAAAAAAAAAAAAAACA////QAAAAAAAAAAAAAAAAAAAAAAAAACA////EAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAABg////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA///fAAAAAAAAAAAAAAAAAAAAAAAAAAAQQEAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABCv/2AAAAAAAAAAAAAAAAAAAAAAAAAAEM///+8QAAAAAAAAAAAAAAAAAAAAAAAQz///7zAAAAAAAAAAAAAAAAAAAAAAABDP///vIAAAAAAAAAAAAAAAAAAAAAAAAM///+8wAAAAAAAAAAAAAAAAAAAAAAAAn///7zAAAAAAAAAAAAAAAAAAAAAAAABQ////YAAAAAAAAAAAAAAAAAAAAAAAABDv//+vAAAAAAAAAAAAAAAAAAAAAAAAAJ///+8QAAAAAAAAAAAAAAAAAAAAAAAAIP///4AAAAAAAAAAAAAAAAAAAAAAAAAAj///7xAAAAAAAAAAAAAAAAAAAAAAAAAA7///nwAAAAAAAAAAAAAAAAAAAAAAAABA////UAAAAAAAAAAAAAAAAAAAAAAAAACA////EAAAAAAAAAAAAAAAAAAAAAAAAAC////PAAAAAAAAAAAAAAAAAAAAAAAAAADv//+/AAAAAAAAAAAAAAAAAAAAAAAAAAD///+AAAAAAAAAAAAAAAAAAAAAAAAAACD///+AAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAADD///+AAAAAAAAAAAAAAAAAAAAAAAAAAAD///+AAAAAAAAAAAAAAAAAAAAAAAAAAAD///+vAAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAACP////AAAAAAAAAAAAAAAAAAAAAAAAAABg////QAAAAAAAAAAAAAAAAAAAAAAAAAAQ////jwAAAAAAAAAAAAAAAAAAAAAAAAAAr///3wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///2AAAAAAAAAAAAAAAAAAAAAAAAAAAL///98AAAAAAAAAAAAAAAAAAAAAAAAAADD///+AAAAAAAAAAAAAAAAAAAAAAAAAAACP////MAAAAAAAAAAAAAAAAAAAAAAAAAAAz///3xAAAAAAAAAAAAAAAAAAAAAAAAAAIO///88QAAAAAAAAAAAAAAAAAAAAAAAAADDv//+fAAAAAAAAAAAAAAAAAAAAAAAAAAAw7///nwAAAAAAAAAAAAAAAAAAAAAAAAAAMO///88QAAAAAAAAAAAAAAAAAAAAAAAAADDv/58AAAAAAAAAAAAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAApAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwEAAAAAAAAAAAAAAAAAAAAAAAAAAAABDv7zAAAAAAAAAAAAAAAAAAAAAAAAAAAJ///+8wAAAAAAAAAAAAAAAAAAAAAAAAAACf///vMAAAAAAAAAAAAAAAAAAAAAAAAAAAn///7zAAAAAAAAAAAAAAAAAAAAAAAAAAAK///+8wAAAAAAAAAAAAAAAAAAAAAAAAABDP///fEAAAAAAAAAAAAAAAAAAAAAAAAAAg7///rwAAAAAAAAAAAAAAAAAAAAAAAAAAUP///1AAAAAAAAAAAAAAAAAAAAAAAAAAAL///98AAAAAAAAAAAAAAAAAAAAAAAAAACD///9gAAAAAAAAAAAAAAAAAAAAAAAAAACv///fAAAAAAAAAAAAAAAAAAAAAAAAAABQ////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////jwAAAAAAAAAAAAAAAAAAAAAAAAAAv///zwAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAYP///0AAAAAAAAAAAAAAAAAAAAAAAAAAQP///1AAAAAAAAAAAAAAAAAAAAAAAAAAQP///4AAAAAAAAAAAAAAAAAAAAAAAAAAIP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAEP///4AAAAAAAAAAAAAAAAAAAAAAAAAAQP///4AAAAAAAAAAAAAAAAAAAAAAAAAAQP///2AAAAAAAAAAAAAAAAAAAAAAAAAAUP///0AAAAAAAAAAAAAAAAAAAAAAAAAAgP///yAAAAAAAAAAAAAAAAAAAAAAAAAAr///7wAAAAAAAAAAAAAAAAAAAAAAAAAA7///rwAAAAAAAAAAAAAAAAAAAAAAAAAw////YAAAAAAAAAAAAAAAAAAAAAAAAACf///vEAAAAAAAAAAAAAAAAAAAAAAAABDv//+PAAAAAAAAAAAAAAAAAAAAAAAAAI///+8gAAAAAAAAAAAAAAAAAAAAAAAAMP///4AAAAAAAAAAAAAAAAAAAAAAAAAQz///zwAAAAAAAAAAAAAAAAAAAAAAAACf///vMAAAAAAAAAAAAAAAAAAAAAAAAHD///9gAAAAAAAAAAAAAAAAAAAAAAAAYP///2AAAAAAAAAAAAAAAAAAAAAAAABg////jwAAAAAAAAAAAAAAAAAAAAAAAGD///9wAAAAAAAAAAAAAAAAAAAAAAAAAGD//2AAAAAAAAAAAAAAAAAAAAAAAAAAAABgQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgv7+/AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAABQ////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAABAAAAAAAABA///vAAAAAAAAAAAAAAAAMP+vUAAAAABA//+/AAAAACBwz88AAAAAj////++fQABA//+/ABBgv/////8gAAAAz////////9+v///fr/////////9wAAAAMIDP///////////////////vr2AQAAAAAAAAEGCv7//////////fj0AAAAAAAAAAAAAAAAAAAHD/////3yAAAAAAAAAAAAAAAAAAAAAAEN///////48AAAAAAAAAAAAAAAAAAAAAr///74D///9QAAAAAAAAAAAAAAAAAACA////UAC////vMAAAAAAAAAAAAAAAAED///+vAAAg7///zxAAAAAAAAAAAAAAEO///+8QAAAAUP///58AAAAAAAAAAAAAz////1AAAAAAAK////9gAAAAAAAAAAAAcO//jwAAAAAAABDv/88wAAAAAAAAAAAAADCvEAAAAAAAAABQjxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIICAYAAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAABBAQEBAQEBAcP//z0BAQEBAQEBAAAAAAED/////////////////////////AAAAAED/////////////////////////AAAAADC/v7+/v7+/z///77+/v7+/v7+/AAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAML+/jwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECvv48QAAAAAAAAAAAAAAAAAAAAAAAAQP/////PEAAAAAAAAAAAAAAAAAAAAAAAz///////cAAAAAAAAAAAAAAAAAAAAAAA////////jwAAAAAAAAAAAAAAAAAAAAAA3///////gAAAAAAAAAAAAAAAAAAAAAAAYP//////UAAAAAAAAAAAAAAAAAAAAAAAAL/////vAAAAAAAAAAAAAAAAAAAAAAAAAO////+AAAAAAAAAAAAAAAAAAAAAAAAAMP////8QAAAAAAAAAAAAAAAAAAAAAAAAcP///58AAAAAAAAAAAAAAAAAAAAAAAAAr////zAAAAAAAAAAAAAAAAAAAAAAAAAA7///vwAAAAAAAAAAAAAAAAAAAAAAAAAw////YAAAAAAAAAAAAAAAAAAAAAAAAABg///fAAAAAAAAAAAAAAAAAAAAAAAAAAAgQEAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQEBAQEBAQEBAQEBAQEBAIAAAAAAAAAD/////////////////////gAAAAAAAAAD/////////////////////gAAAAAAAAAC/v7+/v7+/v7+/v7+/v7+/YAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUK+/jxAAAAAAAAAAAAAAAAAAAAAAAABg/////+8gAAAAAAAAAAAAAAAAAAAAABD///////+fAAAAAAAAAAAAAAAAAAAAAED////////fAAAAAAAAAAAAAAAAAAAAAED////////PAAAAAAAAAAAAAAAAAAAAAADv//////+AAAAAAAAAAAAAAAAAAAAAAAAw7////78AAAAAAAAAAAAAAAAAAAAAAAAAEGCAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAvAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJ/fYAAAAAAAAAAAAAAAAAAAAAAAAAAAIP///0AAAAAAAAAAAAAAAAAAAAAAAAAAn///vwAAAAAAAAAAAAAAAAAAAAAAAAAg////YAAAAAAAAAAAAAAAAAAAAAAAAACf///fAAAAAAAAAAAAAAAAAAAAAAAAABDv//9gAAAAAAAAAAAAAAAAAAAAAAAAAID//98AAAAAAAAAAAAAAAAAAAAAAAAAEO///2AAAAAAAAAAAAAAAAAAAAAAAAAAgP//3wAAAAAAAAAAAAAAAAAAAAAAAAAQ7///YAAAAAAAAAAAAAAAAAAAAAAAAACA///fAAAAAAAAAAAAAAAAAAAAAAAAABDv//9gAAAAAAAAAAAAAAAAAAAAAAAAAID//+8QAAAAAAAAAAAAAAAAAAAAAAAAAO///4AAAAAAAAAAAAAAAAAAAAAAAAAAYP//7xAAAAAAAAAAAAAAAAAAAAAAAAAA3///gAAAAAAAAAAAAAAAAAAAAAAAAABg///vEAAAAAAAAAAAAAAAAAAAAAAAAADf//+AAAAAAAAAAAAAAAAAAAAAAAAAAGD//+8QAAAAAAAAAAAAAAAAAAAAAAAAAN///4AAAAAAAAAAAAAAAAAAAAAAAAAAYP///xAAAAAAAAAAAAAAAAAAAAAAAAAA3///nwAAAAAAAAAAAAAAAAAAAAAAAABA////IAAAAAAAAAAAAAAAAAAAAAAAAAC///+fAAAAAAAAAAAAAAAAAAAAAAAAAED///8gAAAAAAAAAAAAAAAAAAAAAAAAAL///58AAAAAAAAAAAAAAAAAAAAAAAAAQP///yAAAAAAAAAAAAAAAAAAAAAAAAAAv///nwAAAAAAAAAAAAAAAAAAAAAAAABA////IAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAACD///9AAAAAAAAAAAAAAAAAAAAAAAAAAJ///78AAAAAAAAAAAAAAAAAAAAAAAAAIP///0AAAAAAAAAAAAAAAAAAAAAAAAAAn///vwAAAAAAAAAAAAAAAAAAAAAAAAAg////QAAAAAAAAAAAAAAAAAAAAAAAAACf//+/AAAAAAAAAAAAAAAAAAAAAAAAAAAgn+9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECAv7+fcCAAAAAAAAAAAAAAAAAAAABA3/////////+fEAAAAAAAAAAAAAAAAGD/////////////zxAAAAAAAAAAAAAAMP///++AMABAj////88AAAAAAAAAAAAAz///7zAAAAAAAGD///+AAAAAAAAAAABg////cAAAAAAAAED////vAAAAAAAAAACv///fAAAAAAAAAL//////YAAAAAAAABD///+PAAAAAAAAQP//3///rwAAAAAAAFD///9AAAAAAAAAv/+fj///7wAAAAAAAID///8QAAAAAABA//8gcP///yAAAAAAAK////8AAAAAAAC//78AQP///0AAAAAAAL///88AAAAAAED//0AAQP///3AAAAAAAM///78AAAAAAL//vwAAIP///4AAAAAAAP///78AAAAAQP//QAAAAP///4AAAAAAAP///78AAAAAv/+/AAAAAP///4AAAAAAAP///78AAABA//9AAAAAAP///4AAAAAAAP///78AAAC//78AAAAAAP///4AAAAAAAL///78AAED//0AAAAAAQP///4AAAAAAAL///78AAL//vwAAAAAAQP///2AAAAAAAJ////8AQP//QAAAAAAAUP///0AAAAAAAID///8Qv/+/AAAAAAAAgP///xAAAAAAAED///+A//9AAAAAAAAAr///3wAAAAAAAADv/////78AAAAAAAAA7///nwAAAAAAAACf/////0AAAAAAAABg////QAAAAAAAAABA////vwAAAAAAABDf///fAAAAAAAAAAAAv///7zAAAAAAEM////9QAAAAAAAAAAAAEO////+fYECA3////58AAAAAAAAAAAAAADDv////////////nwAAAAAAAAAAAAAAAAAQn////////99gAAAAAAAAAAAAAAAAAAAAABBAgIBwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQEAQAAAAAAAAAAAAAAAAAAAAAAAAIL////9AAAAAAAAAAAAAAAAAAAAAAACA//////9AAAAAAAAAAAAAAAAAAAAAQN////////9AAAAAAAAAAAAAAAAAABCv/////7////9AAAAAAAAAAAAAAAAAcO/////fUAD///9AAAAAAAAAAAAAAAAAv////4AQAAD///9AAAAAAAAAAAAAAAAAMP+/IAAAAAD///9AAAAAAAAAAAAAAAAAAEAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAL+/v7+/v7/////Pv7+/v78wAAAAAAAAAP////////////////////9AAAAAAAAAAP////////////////////9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBQgK+/v4BgEAAAAAAAAAAAAAAAAAAQgO///////////4AQAAAAAAAAAAAAADDf///////////////PEAAAAAAAAAAAMO////+/YEBAQHDf////zwAAAAAAAAAAj///71AAAAAAAAAQz////2AAAAAAAAAAAHDvMAAAAAAAAAAAEO///88AAAAAAAAAAAAAAAAAAAAAAAAAAJ////8QAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///8wAAAAAAAAAAAAAAAAAAAAAAAAAJ////8AAAAAAAAAAAAAAAAAAAAAAAAAAN///68AAAAAAAAAAAAAAAAAAAAAAAAAQP///2AAAAAAAAAAAAAAAAAAAAAAAAAAv///3wAAAAAAAAAAAAAAAAAAAAAAAABw////UAAAAAAAAAAAAAAAAAAAAAAAADDv//+vAAAAAAAAAAAAAAAAAAAAAAAAEM///98QAAAAAAAAAAAAAAAAAAAAAAAAz///7zAAAAAAAAAAAAAAAAAAAAAAAACf////UAAAAAAAAAAAAAAAAAAAAAAAAJ////9gAAAAAAAAAAAAAAAAAAAAAAAAn////2AAAAAAAAAAAAAAAAAAAAAAAACf////YAAAAAAAAAAAAAAAAAAAAAAAAJ////9gAAAAAAAAAAAAAAAAAAAAAAAAn////2AAAAAAAAAAAAAAAAAAAAAAAACf////YAAAAAAAAAAAAAAAAAAAAAAAAJ///+8wAAAAAAAAAAAAAAAAAAAAAAAAQP/////////////////////PAAAAAAAAQP////////////////////+/AAAAAAAAQP////////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQICfv7+AYBAAAAAAAAAAAAAAAAAAEIDv//////////+fEAAAAAAAAAAAAAAw3///////////////7zAAAAAAAAAAACD/////n1AQACBQv////+8gAAAAAAAAAACA/88wAAAAAAAAAHD///+fAAAAAAAAAAAAYBAAAAAAAAAAAACv////EAAAAAAAAAAAAAAAAAAAAAAAAABQ////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABg///vAAAAAAAAAAAAAAAAAAAAAAAAAADP//+PAAAAAAAAAAAAAAAAAAAAAAAAEJ///88QAAAAAAAAAAAAAAAAABBAQECP7///zxAAAAAAAAAAAAAAAAAAAED//////89gAAAAAAAAAAAAAAAAAAAAAID///////+vYAAAAAAAAAAAAAAAAAAAAECAgICv7////78QAAAAAAAAAAAAAAAAAAAAAAAAAGDv///PEAAAAAAAAAAAAAAAAAAAAAAAAABA////gAAAAAAAAAAAAAAAAAAAAAAAAAAAr///3wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///yAAAAAAAAAAAAAAAAAAAAAAAAAAcP///0AAAAAAAAAAAAAAAAAAAAAAAAAAgP///0AAAAAAAAAAAAAAAAAAAAAAAAAAj////xAAAAAAAAAAEAAAAAAAAAAAAAAA3///zwAAAAAAABCvrxAAAAAAAAAAAACA////YAAAAAAAEM///99AAAAAAAAAEI/////PAAAAAAAAAHD/////34+AgICf7////+8wAAAAAAAAAABQ7///////////////zyAAAAAAAAAAAAAAEIDf/////////89gAAAAAAAAAAAAAAAAAAAAIECAgIBAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAI8gAAAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAr///rwAAAAAAAAAAAAAAAAAAAAAAAAAg////QAAAAAAAAAAAAAAAAAAAAAAAAACP///fAAAAAAAAAAAAAAAAAAAAAAAAABDv//9wAAAAAAAAAAAAAAAAAAAAAAAAAGD//+8QAAAAAAAAAAAAAAAAAAAAAAAAAN///58AAAAAAAAAAAAAAAAAAAAAAAAAQP///yAAAAAAAAAAAAAAAAAAAAAAAAAAr///vwAAAAAAAAAAAAAAAAAAAAAAAAAg////YAAAAAAAAAAAAAAAAAAAAAAAAACP///fAAAAAACPv78AAAAAAAAAAAAAABDv//+AAAAAAAD///8AAAAAAAAAAAAAAGD///8gAAAAAAD///8AAAAAAAAAAAAAAN///58AAAAAAAD///8AAAAAAAAAAAAAQP///0AAAAAAAAD///8AAAAAAAAAAAAAr///zwAAAAAAACD///8AAAAAAAAAAAAg////YAAAAAAAAED///8AAAAAAAAAAACP///vEAAAAAAAAED///8AAAAAAAAAAADv///PgICAgICAgJ////+AgIBgAAAAAAD///////////////////////+/AAAAAAD///////////////////////+/AAAAAABAQEBAQEBAQEBAQHD///9AQEAwAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQEBAQEBAQEBAQEBAQEAAAAAAAAAAAAD//////////////////88AAAAAAAAAAAD//////////////////68AAAAAAAAAAAD///+fgICAgICAgICAgEAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AIHCvv7+/gCAAAAAAAAAAAAAAAAD////P//////////+PAAAAAAAAAAAAAAD/////////////////rwAAAAAAAAAAAAC/v7+fUBAAABBg3////4AAAAAAAAAAAAAAAAAAAAAAAAAAEN///+8QAAAAAAAAAAAAAAAAAAAAAAAAAGD///9wAAAAAAAAAAAAAAAAAAAAAAAAAAD///+vAAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC////vAAAAAAAAAAAAAAAAAAAAAAAAAAC////vAAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAD///+fAAAAAAAAAAAAAAAAAAAAAAAAAGD///9gAAAAAAAAADC/EAAAAAAAAAAAEN///+8QAAAAAAAAUO//32AAAAAAAAAgz////3AAAAAAAAAAYP/////fj4CAgK//////nwAAAAAAAAAAADDf//////////////+PAAAAAAAAAAAAAAAAYN//////////r0AAAAAAAAAAAAAAAAAAAAAgUICAgEAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgcJ+/v4BgEAAAAAAAAAAAAAAAAAAAIL//////////748QAAAAAAAAAAAAAABQ7////////////+8QAAAAAAAAAAAAAED////vj0AQIFCP73AAAAAAAAAAAAAAEO///88gAAAAAAAAEAAAAAAAAAAAAAAAn///7zAAAAAAAAAAAAAAAAAAAAAAAAAg////gAAAAAAAAAAAAAAAAAAAAAAAAABw///vEAAAAAAAAAAAAAAAAAAAAAAAAADP//+fAAAAAAAAAAAAAAAAAAAAAAAAABD///9QAAAAAAAAAAAAAAAAAAAAAAAAAED///8gAAAAMECAUDAAAAAAAAAAAAAAAID///8AAFDf///////fYAAAAAAAAAAAAID//78An////////////88QAAAAAAAAAL///7+f///fn4CAn+////+/AAAAAAAAAL///+///4AAAAAAABCf////YAAAAAAAAL//////QAAAAAAAAAAA3///3wAAAAAAAL////9gAAAAAAAAAAAAYP///zAAAAAAAL///88AAAAAAAAAAAAAMP///2AAAAAAAJ///78AAAAAAAAAAAAAAP///4AAAAAAAID//98AAAAAAAAAAAAAAP///4AAAAAAAGD///8AAAAAAAAAAAAAAP///4AAAAAAADD///8wAAAAAAAAAAAAMP///2AAAAAAAADv//9gAAAAAAAAAAAAUP///zAAAAAAAACf//+/AAAAAAAAAAAAn///3wAAAAAAAABA////QAAAAAAAAAAw////gAAAAAAAAAAAv///7zAAAAAAACDf///fEAAAAAAAAAAAIO////+fcEBgn////+8wAAAAAAAAAAAAADDv////////////7zAAAAAAAAAAAAAAAAAQn////////++fEAAAAAAAAAAAAAAAAAAAABBAgICAQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEBAQEBAQEBAQEBAQEBAQDAAAAAAAAAA/////////////////////78AAAAAAAAA/////////////////////78AAAAAAAAAv7+/v7+/v7+/v7+/v+///58AAAAAAAAAAAAAAAAAAAAAAAAAIP///0AAAAAAAAAAAAAAAAAAAAAAAAAAn///zwAAAAAAAAAAAAAAAAAAAAAAAAAQ7///YAAAAAAAAAAAAAAAAAAAAAAAAACA///vAAAAAAAAAAAAAAAAAAAAAAAAAADf//+AAAAAAAAAAAAAAAAAAAAAAAAAAGD///8gAAAAAAAAAAAAAAAAAAAAAAAAAM///58AAAAAAAAAAAAAAAAAAAAAAAAAQP///zAAAAAAAAAAAAAAAAAAAAAAAAAAr///vwAAAAAAAAAAAAAAAAAAAAAAAAAg////YAAAAAAAAAAAAAAAAAAAAAAAAACf///fAAAAAAAAAAAAAAAAAAAAAAAAABDv//+AAAAAAAAAAAAAAAAAAAAAAAAAAID///8QAAAAAAAAAAAAAAAAAAAAAAAAAN///58AAAAAAAAAAAAAAAAAAAAAAAAAYP///yAAAAAAAAAAAAAAAAAAAAAAAAAAz///vwAAAAAAAAAAAAAAAAAAAAAAAABA////UAAAAAAAAAAAAAAAAAAAAAAAAACv///fAAAAAAAAAAAAAAAAAAAAAAAAACD///9wAAAAAAAAAAAAAAAAAAAAAAAAAJ///+8QAAAAAAAAAAAAAAAAAAAAAAAAEO///58AAAAAAAAAAAAAAAAAAAAAAAAAgP///yAAAAAAAAAAAAAAAAAAAAAAAAAA3///rwAAAAAAAAAAAAAAAAAAAAAAAABg////QAAAAAAAAAAAAAAAAAAAAAAAAABgz//fAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUICvv5+AMAAAAAAAAAAAAAAAAAAAAIDv/////////99gAAAAAAAAAAAAAAAQz///////////////nwAAAAAAAAAAAADP////nzAAABBQz////48AAAAAAAAAAID///9gAAAAAAAAAK////8wAAAAAAAAAN///58AAAAAAAAAABD///+PAAAAAAAAEP///2AAAAAAAAAAAAC///+/AAAAAAAAQP///0AAAAAAAAAAAAC///+/AAAAAAAAIP///0AAAAAAAAAAAAC///+vAAAAAAAAAO///48AAAAAAAAAAADv//9gAAAAAAAAAJ////8wAAAAAAAAAHD//98QAAAAAAAAACDv////gBAAAAAAYP//7zAAAAAAAAAAAAAw7/////+vUCCv///PIAAAAAAAAAAAAAAAEK///////////4AAAAAAAAAAAAAAAAAAAHDv/////////99gAAAAAAAAAAAAAAAwz///z1Bgv///////rxAAAAAAAAAAADDv//+fAAAAACCP7////88QAAAAAAAAIO///58AAAAAAAAAEK////+/AAAAAAAAn///3wAAAAAAAAAAAACf////QAAAAAAQ////jwAAAAAAAAAAAAAQ////rwAAAABA////YAAAAAAAAAAAAAAAv///3wAAAABA////QAAAAAAAAAAAAAAAv////wAAAABA////cAAAAAAAAAAAAAAAz///zwAAAAAQ////nwAAAAAAAAAAAAAg////rwAAAAAAv////zAAAAAAAAAAAACv////UAAAAAAAQP///+8wAAAAAAAAEJ////+/AAAAAAAAAGD/////r4BQQHCf7////+8QAAAAAAAAAABg7///////////////vxAAAAAAAAAAAAAAIJ/v/////////89gAAAAAAAAAAAAAAAAAAAAQGCAgIBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIGCAr6+AYBAAAAAAAAAAAAAAAAAAACCf//////////+fEAAAAAAAAAAAAAAAYO//////////////3zAAAAAAAAAAAABA/////59QQEBQv////88QAAAAAAAAABDf///vMAAAAAAAAGD///+AAAAAAAAAAHD///9QAAAAAAAAAACv///vEAAAAAAAAM///98AAAAAAAAAAAAw////YAAAAAAAAP///58AAAAAAAAAAAAA7///rwAAAAAAIP///4AAAAAAAAAAAAAAv///7wAAAAAAQP///4AAAAAAAAAAAAAAgP///wAAAAAAMP///4AAAAAAAAAAAAAAgP///yAAAAAAAP///4AAAAAAAAAAAAAAgP///0AAAAAAAN///78AAAAAAAAAAAAAr////0AAAAAAAI////8gAAAAAAAAAABw/////0AAAAAAACD////PEAAAAAAAAI///////wAAAAAAAACA////33BAAEBg3///z////wAAAAAAAAAAn/////////////9gn///3wAAAAAAAAAAAHDv////////vzAAv///rwAAAAAAAAAAAAAAUICvn4AwAAAQ////gAAAAAAAAAAAAAAAAAAAAAAAAABg////MAAAAAAAAAAAAAAAAAAAAAAAAADf///fAAAAAAAAAAAAAAAAAAAAAAAAAID///9gAAAAAAAAAAAAAAAAAAAAAAAAYP///88AAAAAAAAAAAAAAAAAAAAAAABw////7zAAAAAAAAAAAAAAAAAAAAAAIL/////vMAAAAAAAAAAAAAAAAAAAADCf/////98wAAAAAAAAAAAAAAAAACBwz///////jxAAAAAAAAAAAAAAAAAAgP///////58gAAAAAAAAAAAAAAAAAAAAUP///89wEAAAAAAAAAAAAAAAAAAAAAAAAL9wIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgN//rzAAAAAAAAAAAAAAAAAAAAAAAACA/////+8gAAAAAAAAAAAAAAAAAAAAAADv//////+AAAAAAAAAAAAAAAAAAAAAAAD///////+AAAAAAAAAAAAAAAAAAAAAAADf//////9gAAAAAAAAAAAAAAAAAAAAAABA/////88AAAAAAAAAAAAAAAAAAAAAAAAAMI+/cBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAwAAAAAAAAAAAAAAAAAAAAAAAAAAAQr///72AAAAAAAAAAAAAAAAAAAAAAAACP//////8gAAAAAAAAAAAAAAAAAAAAAAD///////+AAAAAAAAAAAAAAAAAAAAAAAD///////+AAAAAAAAAAAAAAAAAAAAAAAC///////9gAAAAAAAAAAAAAAAAAAAAAAAw7////58AAAAAAAAAAAAAAAAAAAAAAAAAEGCAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA7AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHDf/68wAAAAAAAAAAAAAAAAAAAAAAAAgP/////vIAAAAAAAAAAAAAAAAAAAAAAA7///////gAAAAAAAAAAAAAAAAAAAAAAA////////jwAAAAAAAAAAAAAAAAAAAAAAz///////cAAAAAAAAAAAAAAAAAAAAAAAQO/////PEAAAAAAAAAAAAAAAAAAAAAAAACCPv3AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECvv58QAAAAAAAAAAAAAAAAAAAAAAAAUP/////PEAAAAAAAAAAAAAAAAAAAAAAA3///////cAAAAAAAAAAAAAAAAAAAAAAA////////nwAAAAAAAAAAAAAAAAAAAAAA3///////gAAAAAAAAAAAAAAAAAAAAAAAYP//////UAAAAAAAAAAAAAAAAAAAAAAAAL/////fAAAAAAAAAAAAAAAAAAAAAAAAAP////+AAAAAAAAAAAAAAAAAAAAAAAAAMP////8QAAAAAAAAAAAAAAAAAAAAAAAAcP///58AAAAAAAAAAAAAAAAAAAAAAAAAr////yAAAAAAAAAAAAAAAAAAAAAAAAAA7///vwAAAAAAAAAAAAAAAAAAAAAAAAAw////UAAAAAAAAAAAAAAAAAAAAAAAAABw///fAAAAAAAAAAAAAAAAAAAAAAAAAABQgIBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAQM/fEAAAAAAAAAAAAAAAAAAAAAAAABCP////jwAAAAAAAAAAAAAAAAAAAAAAUO//////nwAAAAAAAAAAAAAAAAAAACC//////89AAAAAAAAAAAAAAAAAAAAAgP/////vgAAAAAAAAAAAAAAAAAAAAEDf/////68gAAAAAAAAAAAAAAAAAAAQr//////fQAAAAAAAAAAAAAAAAAAAAHDv/////4AQAAAAAAAAAAAAAAAAAAAwv/////+/IAAAAAAAAAAAAAAAAAAAAGD/////72AAAAAAAAAAAAAAAAAAAAAAAID///+PEAAAAAAAAAAAAAAAAAAAAAAAAID//99AAAAAAAAAAAAAAAAAAAAAAAAAAID/////nxAAAAAAAAAAAAAAAAAAAAAAAACA7////+9wAAAAAAAAAAAAAAAAAAAAAAAAIL//////vzAAAAAAAAAAAAAAAAAAAAAAAABQ7/////+PEAAAAAAAAAAAAAAAAAAAAAAAEI//////31AAAAAAAAAAAAAAAAAAAAAAAABAz/////+vIAAAAAAAAAAAAAAAAAAAAAAAAIDv////74AAAAAAAAAAAAAAAAAAAAAAAAAgv//////PQAAAAAAAAAAAAAAAAAAAAAAAAFDv////vwAAAAAAAAAAAAAAAAAAAAAAAAAQj//vIAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQBAAAAAAAAAAv////////////////////0AAAAAAAAAAv////////////////////0AAAAAAAAAAj7+/v7+/v7+/v7+/v7+/vzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQBAAAAAAAAAAv////////////////////0AAAAAAAAAAv////////////////////0AAAAAAAAAAj7+/v7+/v7+/v7+/v7+/vzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAED/nxAAAAAAAAAAAAAAAAAAAAAAAAAAEN///+9gAAAAAAAAAAAAAAAAAAAAAAAAIL//////vyAAAAAAAAAAAAAAAAAAAAAAAABw7/////+AEAAAAAAAAAAAAAAAAAAAAAAAEJ//////30AAAAAAAAAAAAAAAAAAAAAAAABA3/////+vIAAAAAAAAAAAAAAAAAAAAAAAAIDv////73AAAAAAAAAAAAAAAAAAAAAAAAAgv//////PMAAAAAAAAAAAAAAAAAAAAAAAAFDf/////48QAAAAAAAAAAAAAAAAAAAAAAAQj//////vAAAAAAAAAAAAAAAAAAAAAAAAADC/////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAQN//////AAAAAAAAAAAAAAAAAAAAABCf/////99AAAAAAAAAAAAAAAAAAAAAYO//////gAAAAAAAAAAAAAAAAAAAADC//////78gAAAAAAAAAAAAAAAAAAAQgP/////vYAAAAAAAAAAAAAAAAAAAAFDf/////58QAAAAAAAAAAAAAAAAAAAgr//////fQAAAAAAAAAAAAAAAAAAAAHDv/////4AAAAAAAAAAAAAAAAAAAAAAIO////+/IAAAAAAAAAAAAAAAAAAAAAAAAGD/72AAAAAAAAAAAAAAAAAAAAAAAAAAAABwEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGCAv7+vgDAAAAAAAAAAAAAAAAAAACCf///////////fQAAAAAAAAAAAAAAAYP///////////////4AAAAAAAAAAAACf/////59QQEBQn/////9QAAAAAAAAABDv///PIAAAAAAAADDv///fAAAAAAAAAAAQr68AAAAAAAAAAACA////QAAAAAAAAAAAABAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAACP////MAAAAAAAAAAAAAAAAAAAAAAAAGD///+/AAAAAAAAAAAAAAAAAAAAAAAAYP////8wAAAAAAAAAAAAAAAAAAAAABCf////72AAAAAAAAAAAAAAAAAAAAAAEM/////fMAAAAAAAAAAAAAAAAAAAAAAQz////68QAAAAAAAAAAAAAAAAAAAAAACP////nwAAAAAAAAAAAAAAAAAAAAAAACD///+/AAAAAAAAAAAAAAAAAAAAAAAAAFD///9QAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAGC/v78AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFDf/78wAAAAAAAAAAAAAAAAAAAAAAAAIO/////fAAAAAAAAAAAAAAAAAAAAAAAAQP//////IAAAAAAAAAAAAAAAAAAAAAAAMP//////AAAAAAAAAAAAAAAAAAAAAAAAAJ////9gAAAAAAAAAAAAAAAAAAAAAAAAAABggDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIGCAr7+/j4BAAAAAAAAAAAAAAAAAEHDP////////////74AQAAAAAAAAAACA7//////////////////fMAAAAAAAMM//////76+AUEBAgJ/v////7zAAAABQ7////99gAAAAAAAAAAAQj////+8gAAAQ7//vcAAAAAAAAAAAAAAAAGD///+vAAAAMM8wAAAAAAAAAAAAAAAAAACP////QAAAAAAAAAAAAAAAAAAAAAAAAAAQ7///nwAAAAAAAAAAAAAAAAAAAAAAAAAAn///7wAAAAAAAAAAAAAAAAAAAAAAAAAAUP///0AAAAAAAAAAAAAAAAAAAAAAAAAAEP///3AAAAAAAECv7////++vUAAAAAAAAN///58AAAAAn////////////98AAAAAAL///78AAACf////z4CAgM////8AAAAAAL///88AAFD///9gAAAAAAD///8AAAAAAID///8AAM///58AAAAAAAD///8AAAAAAID///8AMP///zAAAAAAAAD///8AAAAAAID///8AcP//7wAAAAAAAAD///8AAAAAAID///8Aj///vwAAAAAAAAD///8AAAAAAID///8Av///nwAAAAAAAAD///8AAAAAAID///8Av///gAAAAAAAAAD///8AAAAAAID///8Av///gAAAAAAAAAD///8AAAAAAID///8Av///gAAAAAAAAAD///8AAAAAAID//98Av///rwAAAAAAAAD///8AAAAAAID//78AgP//vwAAAAAAAAD///8AAAAAAL///78AYP///wAAAAAAAGD///8AAAAAAL///48AIP///1AAAAAAEN////8gAAAAAM///4AAAL///88QAAAQv/+/v/9QAAAAEP///0AAADD////vv7///+8gn/+fAAAAYP//7wAAAABg////////70AAQP//gBAg3///nwAAAAAAIJ+/v7+PIAAAAL/////////vIAAAAAAAAAAAAAAAAAAAABDP//////9gAAAAAAAAAAAAAAAAAAAAAAAQcL+/gCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAQEAgAAAAAAAAAAAAAAAAAAAAAAAAMP/////PAAAAAAAAAAAAAAAAAAAAAAAAj///////IAAAAAAAAAAAAAAAAAAAAAAA3///////cAAAAAAAAAAAAAAAAAAAAAAw////j///zwAAAAAAAAAAAAAAAAAAAACP//+vMP///yAAAAAAAAAAAAAAAAAAAADf//9gAO///3AAAAAAAAAAAAAAAAAAACD///8QAJ///88AAAAAAAAAAAAAAAAAAHD//78AAFD///8gAAAAAAAAAAAAAAAAAM///3AAAAD///9wAAAAAAAAAAAAAAAAIP///yAAAACv///PAAAAAAAAAAAAAAAAcP//zwAAAABg////EAAAAAAAAAAAAAAAz///gAAAAAAg////YAAAAAAAAAAAAAAg////MAAAAAAAz///rwAAAAAAAAAAAABw///fAAAAAAAAcP///xAAAAAAAAAAAADP//+PAAAAAAAAMP///2AAAAAAAAAAACD///9AAAAAAAAAAN///68AAAAAAAAAAHD//+8AAAAAAAAAAI////8QAAAAAAAAAK///79AQEBAQEBAQHD///9gAAAAAAAAEP////////////////////+vAAAAAAAAYP//////////////////////EAAAAAAAr///37+/v7+/v7+/v7/P////UAAAAAAQ////YAAAAAAAAAAAAAAA////nwAAAABg////EAAAAAAAAAAAAAAAr///7wAAAACv///PAAAAAAAAAAAAAAAAYP///1AAABD///9wAAAAAAAAAAAAAAAAEP///58AAGD///8gAAAAAAAAAAAAAAAAAK///+8AAK///88AAAAAAAAAAAAAAAAAAHD///9QAO///4AAAAAAAAAAAAAAAAAAACD///+fAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQEBAQEBAQEAAAAAAAAAAAAAAAAAAAAD/////////////769gAAAAAAAAAAAAAAD/////////////////30AAAAAAAAAAAAD////fv7+/v7+///////9gAAAAAAAAAAD///+AAAAAAAAAEID////vIAAAAAAAAAD///+AAAAAAAAAAABw////jwAAAAAAAAD///+AAAAAAAAAAAAA7///vwAAAAAAAAD///+AAAAAAAAAAAAAv///7wAAAAAAAAD///+AAAAAAAAAAAAAv///3wAAAAAAAAD///+AAAAAAAAAAAAA3///vwAAAAAAAAD///+AAAAAAAAAAABA////YAAAAAAAAAD///+AAAAAAAAAACDP///PAAAAAAAAAAD///+fQEBAQEBQj+///78QAAAAAAAAAAD////////////////PYAAAAAAAAAAAAAD///////////////+/cCAAAAAAAAAAAAD////fv7+/v7+/7/////+AAAAAAAAAAAD///+AAAAAAAAAADC/////nwAAAAAAAAD///+AAAAAAAAAAAAAr////2AAAAAAAAD///+AAAAAAAAAAAAAEP///88AAAAAAAD///+AAAAAAAAAAAAAAL////8AAAAAAAD///+AAAAAAAAAAAAAAL////8gAAAAAAD///+AAAAAAAAAAAAAAK////8QAAAAAAD///+AAAAAAAAAAAAAAL////8AAAAAAAD///+AAAAAAAAAAAAAIP///88AAAAAAAD///+AAAAAAAAAAAAQz////2AAAAAAAAD///+AAAAAAAAAMIDv////vwAAAAAAAAD///////////////////+/EAAAAAAAAAD/////////////////73AAAAAAAAAAAAD////////////vv49QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECAn7+/j4BAAAAAAAAAAAAAAAAAABCA7///////////74AQAAAAAAAAAAAAQN/////////////////fQAAAAAAAAABg/////++fcEBAcJ/f////gAAAAAAAAED/////gBAAAAAAAAAAYO+fAAAAAAAAEO///+8wAAAAAAAAAAAAACAAAAAAAAAAgP///2AAAAAAAAAAAAAAAAAAAAAAAAAQ7///vwAAAAAAAAAAAAAAAAAAAAAAAABw////UAAAAAAAAAAAAAAAAAAAAAAAAAC////vAAAAAAAAAAAAAAAAAAAAAAAAAAD///+vAAAAAAAAAAAAAAAAAAAAAAAAADD///+AAAAAAAAAAAAAAAAAAAAAAAAAAED///9wAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAHD///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAACD///+AAAAAAAAAAAAAAAAAAAAAAAAAAADv//+/AAAAAAAAAAAAAAAAAAAAAAAAAACv////EAAAAAAAAAAAAAAAAAAAAAAAAABg////YAAAAAAAAAAAAAAAAAAAAAAAAAAA7///3wAAAAAAAAAAAAAAAAAAAAAAAAAAcP///48AAAAAAAAAAAAAAAAAAAAAAAAAAM////9wAAAAAAAAAAAAAEC/AAAAAAAAACDv////v0AAAAAAAAAwn///jwAAAAAAAAAw7//////fv4CAv9//////7xAAAAAAAAAAEL////////////////+/IAAAAAAAAAAAAABAr///////////r0AAAAAAAAAAAAAAAAAAABBAcICAcEAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIEBAQEBAQEAwAAAAAAAAAAAAAAAAAAAAgP///////////9+fYAAAAAAAAAAAAAAAgP///////////////99gAAAAAAAAAAAAgP///7+/v7+/3///////nxAAAAAAAAAAgP///wAAAAAAACCA7////68AAAAAAAAAgP///wAAAAAAAAAAMM////+AAAAAAAAAgP///wAAAAAAAAAAACDv///vEAAAAAAAgP///wAAAAAAAAAAAACA////gAAAAAAAgP///wAAAAAAAAAAAAAQ////3wAAAAAAgP///wAAAAAAAAAAAAAAr////yAAAAAAgP///wAAAAAAAAAAAAAAgP///1AAAAAAgP///wAAAAAAAAAAAAAAQP///4AAAAAAgP///wAAAAAAAAAAAAAAQP///4AAAAAAgP///wAAAAAAAAAAAAAAQP///68AAAAAgP///wAAAAAAAAAAAAAAQP///78AAAAAgP///wAAAAAAAAAAAAAAQP///78AAAAAgP///wAAAAAAAAAAAAAAQP///48AAAAAgP///wAAAAAAAAAAAAAAQP///4AAAAAAgP///wAAAAAAAAAAAAAAcP///3AAAAAAgP///wAAAAAAAAAAAAAAn////0AAAAAAgP///wAAAAAAAAAAAAAA3////wAAAAAAgP///wAAAAAAAAAAAABA////rwAAAAAAgP///wAAAAAAAAAAAAC/////QAAAAAAAgP///wAAAAAAAAAAAHD///+/AAAAAAAAgP///wAAAAAAAAAAgP///+8wAAAAAAAAgP///wAAAAAAEGDf/////2AAAAAAAAAAgP///7+/v7/////////vUAAAAAAAAAAAgP///////////////58QAAAAAAAAAAAAgP//////////v59gEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAARQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQBAAAAAAAAAAv////////////////////xAAAAAAAAAAv////////////////////wAAAAAAAAAAv///77+/v7+/v7+/v7+/jwAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///77+/v7+/v7+/v78wAAAAAAAAAAAAv/////////////////9AAAAAAAAAAAAAv/////////////////9AAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///77+/v7+/v7+/v7+/v2AAAAAAAAAAv////////////////////4AAAAAAAAAAv////////////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBAQEBAQEBAQEBAQEBAQEBAEAAAAAAAAED/////////////////////QAAAAAAAAED/////////////////////AAAAAAAAAED////Pv7+/v7+/v7+/v7+/AAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///+fgICAgICAgICAgCAAAAAAAAAAAED//////////////////0AAAAAAAAAAAED//////////////////0AAAAAAAAAAAED///+fgICAgICAgICAgCAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCAn7+/j3AwAAAAAAAAAAAAAAAAAABg3///////////z0AAAAAAAAAAAAAAEM////////////////+fEAAAAAAAAAAw7////++fYEBAgK//////gAAAAAAAABDf////jxAAAAAAAAAgv/+fAAAAAAAAAK////9gAAAAAAAAAAAAAIAAAAAAAAAAQP///68AAAAAAAAAAAAAAAAAAAAAAAAAv////yAAAAAAAAAAAAAAAAAAAAAAAAAg////nwAAAAAAAAAAAAAAAAAAAAAAAABw////UAAAAAAAAAAAAAAAAAAAAAAAAACv////EAAAAAAAAAAAAAAAAAAAAAAAAADf///vAAAAAAAAAAAAAAAAAAAAAAAAAAD///+/AAAAAAAAAAAAAAAAAAAAAAAAAAD///+/AAAAAAAAMICAgICAgICAgAAAAAD///+/AAAAAAAAQP///////////wAAAAD///+/AAAAAAAAIP///////////wAAAAD///+/AAAAAAAAAICAgICAv////wAAAAD///+/AAAAAAAAAAAAAAAAgP///wAAAADv///vAAAAAAAAAAAAAAAAgP///wAAAAC/////EAAAAAAAAAAAAAAAgP///wAAAACP////QAAAAAAAAAAAAAAAgP///wAAAABQ////jwAAAAAAAAAAAAAAgP///wAAAAAA7///3wAAAAAAAAAAAAAAgP///wAAAAAAj////4AAAAAAAAAAAAAAgP///wAAAAAAEO////8wAAAAAAAAAAAAgP///wAAAAAAAGD/////gBAAAAAAABBg3////wAAAAAAAACP/////++/gICPv////////wAAAAAAAAAAYO/////////////////PYAAAAAAAAAAAACCf7//////////vn0AAAAAAAAAAAAAAAAAAAEBggICAQDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACBAQEAAAAAAAAAAAAAAEEBAQBAAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///9AQEBAQEBAQEBAcP///0AAAAAAAID//////////////////////0AAAAAAAID//////////////////////0AAAAAAAID///+AgICAgICAgICAn////0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAID///8AAAAAAAAAAAAAQP///0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQCAAAAAAAAAAv////////////////////4AAAAAAAAAAv////////////////////4AAAAAAAAAAYICAgICAv////4CAgICAgEAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAj7+/v7+/3////7+/v7+/v2AAAAAAAAAAv////////////////////4AAAAAAAAAAv////////////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABKAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQEBAQEBAQEBAQEAwAAAAAAAAAAAAAABA//////////////+/AAAAAAAAAAAAAABA//////////////+/AAAAAAAAAAAAAAAwv7+/v7+/v7/v//+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAADv//+/AAAAAAAAAAAAAAAAAAAAAAAAAAD///+AAAAAAAAAAAAAAAAAAAAAAAAAAFD///9gAAAAAAAAAAAAAAAAAAAAAAAAAL////8gAAAAAAAAACAgAAAAAAAAAAAAYP///78AAAAAAAAAAL/vgCAAAAAAAACA/////0AAAAAAAAAAcP/////Pn4CAn+//////gAAAAAAAAAAAIL////////////////+AAAAAAAAAAAAAAABAn///////////r0AAAAAAAAAAAAAAAAAAAABAYICAcEAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwQEAwAAAAAAAAAAAAAABAQEBAEAAAAAC///+/AAAAAAAAAAAAAHD///+fAAAAAAC///+/AAAAAAAAAAAAUP///88AAAAAAAC///+/AAAAAAAAAAAw7///zxAAAAAAAAC///+/AAAAAAAAABDv///vMAAAAAAAAAC///+/AAAAAAAAEM////9AAAAAAAAAAAC///+/AAAAAAAAn////2AAAAAAAAAAAAC///+/AAAAAACA////jwAAAAAAAAAAAAC///+/AAAAAGD///+fAAAAAAAAAAAAAAC///+/AAAAMP///88QAAAAAAAAAAAAAAC///+/AAAg7///3xAAAAAAAAAAAAAAAAC///+/ABDP///vMAAAAAAAAAAAAAAAAAC///+/AL////9AAAAAAAAAAAAAAAAAAAC///+/n////4AAAAAAAAAAAAAAAAAAAAC///+/gP///88QAAAAAAAAAAAAAAAAAAC///+/AL////+fAAAAAAAAAAAAAAAAAAC///+/ABDf////YAAAAAAAAAAAAAAAAAC///+/AAAw/////zAAAAAAAAAAAAAAAAC///+/AAAAcP///98QAAAAAAAAAAAAAAC///+/AAAAAJ////+/AAAAAAAAAAAAAAC///+/AAAAABDP////gAAAAAAAAAAAAAC///+/AAAAAAAw7////0AAAAAAAAAAAAC///+/AAAAAAAAYP///+8gAAAAAAAAAAC///+/AAAAAAAAAJ/////PAAAAAAAAAAC///+/AAAAAAAAAADP////nwAAAAAAAAC///+/AAAAAAAAAAAg7////2AAAAAAAAC///+/AAAAAAAAAAAAUP///+8wAAAAAAC///+/AAAAAAAAAAAAAID////PEAAAAAC///+/AAAAAAAAAAAAAAC/////rwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACBAQEAQAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9AAAAAAAAAAAAAAAAAAAAAAAAAAID///9wQEBAQEBAQEBAQEBAAAAAAAAAAID////////////////////vAAAAAAAAAID///////////////////+/AAAAAAAAAID///////////////////+vAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABNAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQEBAQDAAAAAAAAAAABBAQEBAMAAAAABA//////8AAAAAAAAAAHD/////vwAAAABA//////9AAAAAAAAAAJ//////zwAAAABA//////+AAAAAAAAAAN///////wAAAABQ//////+vAAAAAAAAEP///////wAAAACA//+////vAAAAAAAAUP//v////wAAAACA//+A7///MAAAAAAAgP//gP///wAAAACA//+Ar///cAAAAAAAv///QP///zAAAACA//+AcP//nwAAAAAA//+/QP///0AAAACv//+AMP//3wAAAABA//+AAP///0AAAAC///+AAO///yAAAABw//9AAP///0AAAAC///+AAK///2AAAACv//8QAP///2AAAAC///+AAHD//48AAADf/88AAP///4AAAADf//9wADD//88AACD//48AAP///4AAAAD///9AAADv//8QAFD//1AAAP///4AAAAD///9AAACv//9QAI///xAAAN///4AAAAD///9AAABg//+PAM//3wAAAL///78AAAD///9AAAAg//+/AP//nwAAAL///78AAED///8wAAAA3///QP//YAAAAL///78AAED///8AAAAAn///v///IAAAAJ///78AAED///8AAAAAYP/////fAAAAAID//+8AAED///8AAAAAIP////+fAAAAAID///8AAGD///8AAAAAAN////9wAAAAAID///8AAID//98AAAAAAJ////8wAAAAAHD///8AAID//78AAAAAAAAAAAAAAAAAAED///8QAID//78AAAAAAAAAAAAAAAAAAED///9AAI///78AAAAAAAAAAAAAAAAAAED///9AAL///78AAAAAAAAAAAAAAAAAAED///9AAL///58AAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAATgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACBAQEBAEAAAAAAAAAAAAEBAQBAAAAAAAID/////jwAAAAAAAAAAAP///0AAAAAAAID/////7wAAAAAAAAAAAP///0AAAAAAAID//////2AAAAAAAAAAAP///0AAAAAAAID//9///78AAAAAAAAAAP///0AAAAAAAID//3D///8gAAAAAAAAAP///0AAAAAAAID//3DP//+fAAAAAAAAAP///0AAAAAAAID//4Bg///vEAAAAAAAAP///0AAAAAAAID//4AQ7///YAAAAAAAAP///0AAAAAAAID//4AAn///zwAAAAAAAP///0AAAAAAAID//58AMP///zAAAAAAAP///0AAAAAAAID//78AAM///58AAAAAAP///0AAAAAAAID//78AAGD//+8QAAAAAP///0AAAAAAAID//78AABDv//9gAAAAAP///0AAAAAAAID//78AAACf///PAAAAAP///0AAAAAAAID//78AAAAw////QAAAAP///0AAAAAAAID//78AAAAAz///nwAAAP///0AAAAAAAID//78AAAAAYP///xAAAP///0AAAAAAAID//78AAAAAEO///3AAAP///0AAAAAAAID//78AAAAAAJ///98AAP///0AAAAAAAID//78AAAAAADD///9AAP///0AAAAAAAID//78AAAAAAADP//+fAP///0AAAAAAAID//78AAAAAAABg////EM///0AAAAAAAID//78AAAAAAAAQ7///cL///0AAAAAAAID//78AAAAAAAAAn///37///0AAAAAAAID//78AAAAAAAAAMP///////0AAAAAAAID//78AAAAAAAAAAM///////0AAAAAAAID//78AAAAAAAAAAGD//////0AAAAAAAID//78AAAAAAAAAABDv/////0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAE8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBQgL+/n4AwAAAAAAAAAAAAAAAAAAAAgO//////////30AAAAAAAAAAAAAAABC///////////////+AAAAAAAAAAAAAAM/////fj1BAYJ//////cAAAAAAAAAAAgP///58AAAAAAAAgz////zAAAAAAAAAg////rwAAAAAAAAAAIO///78AAAAAAACf////IAAAAAAAAAAAAID///9AAAAAAADv//+vAAAAAAAAAAAAABD///+fAAAAAFD///9gAAAAAAAAAAAAAAC////vAAAAAID///8gAAAAAAAAAAAAAACA////MAAAAL////8AAAAAAAAAAAAAAABQ////YAAAAN///78AAAAAAAAAAAAAAABA////gAAAAP///78AAAAAAAAAAAAAAAAQ////jwAAAP///78AAAAAAAAAAAAAAAAA////vwAAAP///78AAAAAAAAAAAAAAAAA////vwAAAP///78AAAAAAAAAAAAAAAAA////vwAAAP///78AAAAAAAAAAAAAAAAA////vwAAAP///78AAAAAAAAAAAAAAAAg////gAAAAN///88AAAAAAAAAAAAAAABA////gAAAAL////8AAAAAAAAAAAAAAABQ////UAAAAID///8wAAAAAAAAAAAAAACA////IAAAAED///9wAAAAAAAAAAAAAADP///fAAAAAADv///PAAAAAAAAAAAAACD///+PAAAAAACP////QAAAAAAAAAAAAJ////8gAAAAAAAg7///zxAAAAAAAAAAQP///58AAAAAAAAAcP///88wAAAAAABg7///7xAAAAAAAAAAAJ//////z4+An9/////vMAAAAAAAAAAAAACf/////////////+8wAAAAAAAAAAAAAAAAQL/////////vnxAAAAAAAAAAAAAAAAAAAAAgUICAcEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBAQEBAQEBAQEAQAAAAAAAAAAAAAAAAAED/////////////769gEAAAAAAAAAAAAED/////////////////73AAAAAAAAAAAED///+fgICAgK+///////+fAAAAAAAAAED///9AAAAAAAAAEID/////gAAAAAAAAED///9AAAAAAAAAAAAw/////yAAAAAAAED///9AAAAAAAAAAAAAj////3AAAAAAAED///9AAAAAAAAAAAAAMP///68AAAAAAED///9AAAAAAAAAAAAAAP///78AAAAAAED///9AAAAAAAAAAAAAAP///78AAAAAAED///9AAAAAAAAAAAAAAP///78AAAAAAED///9AAAAAAAAAAAAAMP///68AAAAAAED///9AAAAAAAAAAAAAgP///3AAAAAAAED///9AAAAAAAAAAAAQ7////yAAAAAAAED///9AAAAAAAAAAEDP////jwAAAAAAAED///9wQEBAQICPz//////PEAAAAAAAAED//////////////////48QAAAAAAAAAED//////////////++fQAAAAAAAAAAAAED///+fgICAgIBQMAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAED///9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEFCAv7+fgDAAAAAAAAAAAAAAAAAAAACA7//////////fQAAAAAAAAAAAAAAAEM///////////////4AAAAAAAAAAAAAAz////9+AUEBgn/////9wAAAAAAAAAACA////nwAAAAAAADDP////MAAAAAAAACD///+vAAAAAAAAAAAg7///vwAAAAAAAJ///+8QAAAAAAAAAAAAgP///0AAAAAAAO///58AAAAAAAAAAAAAEP///58AAAAAUP///1AAAAAAAAAAAAAAAK///+8AAAAAj////xAAAAAAAAAAAAAAAID///8wAAAAv///3wAAAAAAAAAAAAAAAED///9gAAAA7///vwAAAAAAAAAAAAAAADD///+AAAAA////rwAAAAAAAAAAAAAAAAD///+PAAAA////gAAAAAAAAAAAAAAAAAD///+/AAAg////gAAAAAAAAAAAAAAAAAD///+/AAAg////gAAAAAAAAAAAAAAAAAD///+/AAAA////gAAAAAAAAAAAAAAAAAD///+/AAAA////vwAAAAAAAAAAAAAAAAD///+AAAAA7///vwAAAAAAAAAAAAAAAED///+AAAAAv///7wAAAAAAAAAAAAAAAFD///9QAAAAj////xAAAAAAAAAAAAAAAID///8gAAAAUP///2AAAAAAAAAAAAAAAM///88AAAAAAO///68AAAAAAAAAAAAAIP///4AAAAAAAJ////8wAAAAAAAAAAAAn////yAAAAAAACD////PEAAAAAAAAABA////gAAAAAAAAACA////zyAAAAAAAGDv///PAAAAAAAAAAAAn//////Pj4Cf3////88QAAAAAAAAAAAAAJ//////////////gAAAAAAAAAAAAAAAAABQz///////////759AAAAAAAAAAAAAAAAAACBAYICAr+//////vyAAAAAAAAAAAAAAAAAAAAAAABCA/////+8wAAAAAAAAAAAAAAAAAAAAAAAAMO/////PAAAAAAAAAAAAAAAAAAAAAAAAAFD/////YAAAAAAAAAAAAAAAAAAAAAAAAACv////3wAAAAAAAAAAAAAAAAAAAAAAAAAg////7wAAAAAAAAAAAAAAAAAAAAAAAAAAr69gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAMAAAAAAAAAAAAAAAAAAAv//////////////fn0AAAAAAAAAAAAAAv/////////////////+/MAAAAAAAAAAAv///34CAgICAv8//////7zAAAAAAAAAAv///vwAAAAAAAAAgr////+8QAAAAAAAAv///vwAAAAAAAAAAAK////9wAAAAAAAAv///vwAAAAAAAAAAACD////PAAAAAAAAv///vwAAAAAAAAAAAADP////AAAAAAAAv///vwAAAAAAAAAAAAC/////AAAAAAAAv///vwAAAAAAAAAAAAC/////AAAAAAAAv///vwAAAAAAAAAAAAD////fAAAAAAAAv///vwAAAAAAAAAAAGD///+PAAAAAAAAv///vwAAAAAAAAAAMO///+8gAAAAAAAAv///vwAAAAAAAECP7////2AAAAAAAAAAv//////////////////vUAAAAAAAAAAAv////////////////58gAAAAAAAAAAAAv/////////////+PEAAAAAAAAAAAAAAAv///vwAAACDv///PAAAAAAAAAAAAAAAAv///vwAAAABw////gAAAAAAAAAAAAAAAv///vwAAAAAAv////zAAAAAAAAAAAAAAv///vwAAAAAAMP///88AAAAAAAAAAAAAv///vwAAAAAAAID///+AAAAAAAAAAAAAv///vwAAAAAAAADf////MAAAAAAAAAAAv///vwAAAAAAAABA////zwAAAAAAAAAAv///vwAAAAAAAAAAj////4AAAAAAAAAAv///vwAAAAAAAAAAEO////8wAAAAAAAAv///vwAAAAAAAAAAAFD////PAAAAAAAAv///vwAAAAAAAAAAAACv////gAAAAAAAv///vwAAAAAAAAAAAAAg7////zAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABTAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgcI+/v7+AYCAAAAAAAAAAAAAAAAAAQL/////////////PYAAAAAAAAAAAAACA/////////////////88wAAAAAAAAAHD/////z4BAQFCAv//////vEAAAAAAAIP///+9AAAAAAAAAACCf//9gAAAAAAAAj////0AAAAAAAAAAAAAAQHAAAAAAAAAAv///3wAAAAAAAAAAAAAAAAAAAAAAAAAA3///vwAAAAAAAAAAAAAAAAAAAAAAAAAAv///3wAAAAAAAAAAAAAAAAAAAAAAAAAAn////3AAAAAAAAAAAAAAAAAAAAAAAAAAQP////+AAAAAAAAAAAAAAAAAAAAAAAAAAJ//////33AgAAAAAAAAAAAAAAAAAAAAAACf////////v3AgAAAAAAAAAAAAAAAAAAAAUN//////////z2AQAAAAAAAAAAAAAAAAAABgz//////////vcAAAAAAAAAAAAAAAAAAAADCAz////////68QAAAAAAAAAAAAAAAAAAAAACCA7/////+vAAAAAAAAAAAAAAAAAAAAAAAAEID/////YAAAAAAAAAAAAAAAAAAAAAAAAABw////vwAAAAAAAAAAAAAAAAAAAAAAAAAA3////wAAAAAAAAAAAAAAAAAAAAAAAAAAn////wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAv////wAAAAAAIDAAAAAAAAAAAAAAAAAg////zwAAAAAQz+9QAAAAAAAAAAAAABDP////cAAAAADP////v1AAAAAAAAAAQM/////fAAAAAABg///////vv4+AgK/f/////+8wAAAAAAAAML//////////////////zzAAAAAAAAAAAABAn+///////////89gAAAAAAAAAAAAAAAAAAAwQICAgHBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQQEBAQEBAQEBAQEBAQEBAQEBAQEBAAABA///////////////////////////fAABA//////////////////////////+/AAAwv7+/v7+/v7/f////v7+/v7+/v7+AAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAACA////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQEAgAAAAAAAAAAAAAABAQEAwAAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+/AAAAAAD///+AAAAAAAAAAAAAAAD///+fAAAAAADv//+PAAAAAAAAAAAAAAD///+AAAAAAAC////PAAAAAAAAAAAAAED///9gAAAAAABw////IAAAAAAAAAAAAJ////8QAAAAAAAg////vwAAAAAAAAAAMP///58AAAAAAAAAj////78gAAAAAABw7////yAAAAAAAAAAEM//////z6+Pv+//////YAAAAAAAAAAAABC//////////////+9gAAAAAAAAAAAAAAAAYN//////////nyAAAAAAAAAAAAAAAAAAAAAgUICAcEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABWAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAQEAAAAAAAAAAAAAAAAAAAABAQEAgAK////8QAAAAAAAAAAAAAAAAADD///9gAGD///9gAAAAAAAAAAAAAAAAAI////8QABD///+vAAAAAAAAAAAAAAAAAN///68AAACv////EAAAAAAAAAAAAAAAIP///2AAAABg////UAAAAAAAAAAAAAAAcP///xAAAAAQ////nwAAAAAAAAAAAAAAz///rwAAAAAAr///7wAAAAAAAAAAAAAg////YAAAAAAAYP///0AAAAAAAAAAAABw////EAAAAAAAEP///48AAAAAAAAAAACv//+vAAAAAAAAAK///98AAAAAAAAAABD///9gAAAAAAAAAGD///8wAAAAAAAAAGD///8QAAAAAAAAABD///+AAAAAAAAAAK///68AAAAAAAAAAACv///PAAAAAAAAAP///2AAAAAAAAAAAABg////IAAAAAAAUP///xAAAAAAAAAAAAAQ////cAAAAAAAn///rwAAAAAAAAAAAAAAr///vwAAAAAA7///YAAAAAAAAAAAAAAAYP///xAAAABQ////EAAAAAAAAAAAAAAAEP///2AAAACP//+vAAAAAAAAAAAAAAAAAK///68AAADf//9gAAAAAAAAAAAAAAAAAGD///8AADD//+8QAAAAAAAAAAAAAAAAABD///9QAI///58AAAAAAAAAAAAAAAAAAACv//+fAM///1AAAAAAAAAAAAAAAAAAAABg///vIP//7wAAAAAAAAAAAAAAAAAAAAAQ////r///nwAAAAAAAAAAAAAAAAAAAAAAr///////UAAAAAAAAAAAAAAAAAAAAAAAYP/////vAAAAAAAAAAAAAAAAAAAAAAAAEP////+fAAAAAAAAAAAAAAAAAAAAAAAAAK////9QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAVwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBAQDAAAAAAAAAAAAAAAAAAAAAAAEBAQL///88AAAAAAAAAAAAAAAAAAAAAAP///4D///8AAAAAAAAAAAAAAAAAAAAAIP///4D///8QAAAAAAAAAAAAAAAAAAAAQP///0D///9AAAAAAADv////jwAAAAAAcP//3zD///9QAAAAABD/////vwAAAAAAgP//vwD///+AAAAAAED/////7wAAAAAAr///gADf//+PAAAAAHD//////wAAAAAAv///cAC///+/AAAAAID//7///0AAAAAA////QACP///PAAAAAL//74D//2AAAAAQ////EACA////AAAAAN//v2D//4AAAABA////AABA////EAAAAP//n0D//68AAABg//+/AAAw////QAAAQP//gAD//88AAACA//+fAAAA////UAAAYP//QADv//8AAACv//+AAAAA3///gAAAgP//MAC///8gAAC///9QAAAAv///jwAAv///AACf//9AAADv//8wAAAAj///vwAAz//fAACA//9wAAD///8AAAAAgP//zwAA//+/AABA//+PAED//98AAAAAQP///wAw//+AAAAw//+/AFD//78AAAAAMP///wBQ//9wAAAA///fAID//48AAAAAAP///0CA//9AAAAAz///AJ///3AAAAAAAN///0Cv//8QAAAAv///QL///0AAAAAAAL///4DP//8AAAAAgP//UO///yAAAAAAAJ///4D//78AAAAAYP//gP///wAAAAAAAID//9///58AAAAAQP//3///vwAAAAAAAFD//////4AAAAAAEP//////rwAAAAAAAED//////1AAAAAAAP//////gAAAAAAAAAD//////zAAAAAAAL//////UAAAAAAAAADv/////wAAAAAAAJ//////QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAQEAQAAAAAAAAAAAAAAAgQEBAEAAAAID///+AAAAAAAAAAAAAAADf///fEAAAABDf////IAAAAAAAAAAAAID///9QAAAAAABA////rwAAAAAAAAAAEO///68AAAAAAAAAr////0AAAAAAAAAAn///7yAAAAAAAAAAIO///88AAAAAAAAw////gAAAAAAAAAAAAHD///9gAAAAAAC////PAAAAAAAAAAAAAADP///vEAAAAGD///9AAAAAAAAAAAAAAABA////gAAAEN///48AAAAAAAAAAAAAAAAAj////yAAgP//7xAAAAAAAAAAAAAAAAAAEO///68g7///YAAAAAAAAAAAAAAAAAAAAGD////P//+/AAAAAAAAAAAAAAAAAAAAAAC///////8gAAAAAAAAAAAAAAAAAAAAAAAg/////48AAAAAAAAAAAAAAAAAAAAAAAAw/////78AAAAAAAAAAAAAAAAAAAAAAAC///////9gAAAAAAAAAAAAAAAAAAAAAGD//++////vEAAAAAAAAAAAAAAAAAAAEO///4Ag7///jwAAAAAAAAAAAAAAAAAAgP//7xAAgP///yAAAAAAAAAAAAAAAAAg////YAAAEO///78AAAAAAAAAAAAAAAC////fAAAAAHD///9QAAAAAAAAAAAAAFD///9AAAAAAADf///fEAAAAAAAAAAAAN///78AAAAAAABQ////gAAAAAAAAAAAgP///zAAAAAAAAAAv////yAAAAAAAAAg7///nwAAAAAAAAAAQP///68AAAAAAACv///vIAAAAAAAAAAAAK////9AAAAAAED///+AAAAAAAAAAAAAACD////fAAAAAM///98QAAAAAAAAAAAAAACP////gAAAcP///2AAAAAAAAAAAAAAAAAQ7///7xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAQEAAAAAAAAAAAAAAAAAAAABAQEAgAJ////9AAAAAAAAAAAAAAAAAAHD///9AACDv///fAAAAAAAAAAAAAAAAEO///68AAACA////YAAAAAAAAAAAAAAAgP///yAAAAAQ7///3wAAAAAAAAAAAAAQ7///nwAAAAAAcP///2AAAAAAAAAAAACf///vEAAAAAAAAN///+8QAAAAAAAAACD///+AAAAAAAAAAGD///+AAAAAAAAAAJ///98QAAAAAAAAAAC////vEAAAAAAAMP///2AAAAAAAAAAAABA////gAAAAAAAv///3wAAAAAAAAAAAAAAr///7xAAAABA////QAAAAAAAAAAAAAAAIP///58AAAC///+/AAAAAAAAAAAAAAAAAJ////8gAFD///9AAAAAAAAAAAAAAAAAABDv//+fAN///58AAAAAAAAAAAAAAAAAAACA////gP///yAAAAAAAAAAAAAAAAAAAAAQ7///////gAAAAAAAAAAAAAAAAAAAAAAAYP/////vEAAAAAAAAAAAAAAAAAAAAAAAAN////+AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQEBAQEBAQEBAQEBAQEBAQDAAAAAAAACA/////////////////////78AAAAAAACA/////////////////////78AAAAAAABgv7+/v7+/v7+/v7+/z////68AAAAAAAAAAAAAAAAAAAAAAAAAj////zAAAAAAAAAAAAAAAAAAAAAAAABA////gAAAAAAAAAAAAAAAAAAAAAAAABDf///PAAAAAAAAAAAAAAAAAAAAAAAAAI////8wAAAAAAAAAAAAAAAAAAAAAAAAQP///4AAAAAAAAAAAAAAAAAAAAAAAAAQ3///zwAAAAAAAAAAAAAAAAAAAAAAAACP////MAAAAAAAAAAAAAAAAAAAAAAAAFD///+AAAAAAAAAAAAAAAAAAAAAAAAAEO///88AAAAAAAAAAAAAAAAAAAAAAAAAr////zAAAAAAAAAAAAAAAAAAAAAAAABQ////gAAAAAAAAAAAAAAAAAAAAAAAABDv///PAAAAAAAAAAAAAAAAAAAAAAAAAK////8wAAAAAAAAAAAAAAAAAAAAAAAAUP///4AAAAAAAAAAAAAAAAAAAAAAAAAQ7///zwAAAAAAAAAAAAAAAAAAAAAAAACv////MAAAAAAAAAAAAAAAAAAAAAAAAFD///+AAAAAAAAAAAAAAAAAAAAAAAAAEO///88AAAAAAAAAAAAAAAAAAAAAAAAAr////zAAAAAAAAAAAAAAAAAAAAAAAABQ////gAAAAAAAAAAAAAAAAAAAAAAAABDv///PAAAAAAAAAAAAAAAAAAAAAAAAAK////8wAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////4AAAAAAAP///////////////////////3AAAAAAAP///////////////////////0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFsAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQICAgICAgICAgGAAAAAAAAAAAAAAAAAAgP///////////78AAAAAAAAAAAAAAAAAgP///////////78AAAAAAAAAAAAAAAAAgP//34CAgICAgGAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//z0BAQEBAQDAAAAAAAAAAAAAAAAAAgP///////////78AAAAAAAAAAAAAAAAAgP///////////78AAAAAAAAAAAAAAAAAYL+/v7+/v7+/v48AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQgO9AAAAAAAAAAAAAAAAAAAAAAAAAAACf//+/AAAAAAAAAAAAAAAAAAAAAAAAAAAw////QAAAAAAAAAAAAAAAAAAAAAAAAAAAv///vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP///zAAAAAAAAAAAAAAAAAAAAAAAAAAAL///58AAAAAAAAAAAAAAAAAAAAAAAAAAED///8gAAAAAAAAAAAAAAAAAAAAAAAAAAC///+fAAAAAAAAAAAAAAAAAAAAAAAAAABA////IAAAAAAAAAAAAAAAAAAAAAAAAAAAv///nwAAAAAAAAAAAAAAAAAAAAAAAAAAQP///yAAAAAAAAAAAAAAAAAAAAAAAAAAAN///58AAAAAAAAAAAAAAAAAAAAAAAAAAGD///8gAAAAAAAAAAAAAAAAAAAAAAAAAADf//+AAAAAAAAAAAAAAAAAAAAAAAAAAABg///vEAAAAAAAAAAAAAAAAAAAAAAAAAAA3///gAAAAAAAAAAAAAAAAAAAAAAAAAAAYP//7xAAAAAAAAAAAAAAAAAAAAAAAAAAAN///4AAAAAAAAAAAAAAAAAAAAAAAAAAAGD//+8QAAAAAAAAAAAAAAAAAAAAAAAAAADf//+AAAAAAAAAAAAAAAAAAAAAAAAAAACA///vEAAAAAAAAAAAAAAAAAAAAAAAAAAQ7///YAAAAAAAAAAAAAAAAAAAAAAAAAAAgP//3wAAAAAAAAAAAAAAAAAAAAAAAAAAEO///2AAAAAAAAAAAAAAAAAAAAAAAAAAAID//98AAAAAAAAAAAAAAAAAAAAAAAAAABDv//9gAAAAAAAAAAAAAAAAAAAAAAAAAACA///fAAAAAAAAAAAAAAAAAAAAAAAAAAAQ7///YAAAAAAAAAAAAAAAAAAAAAAAAAAAn///3wAAAAAAAAAAAAAAAAAAAAAAAAAAIP///1AAAAAAAAAAAAAAAAAAAAAAAAAAAJ///78AAAAAAAAAAAAAAAAAAAAAAAAAACD///9AAAAAAAAAAAAAAAAAAAAAAAAAAACf//+/AAAAAAAAAAAAAAAAAAAAAAAAAAAg////QAAAAAAAAAAAAAAAAAAAAAAAAAAAn///vwAAAAAAAAAAAAAAAAAAAAAAAAAAIP///0AAAAAAAAAAAAAAAAAAAAAAAAAAAJ/fYBAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAXQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCAgICAgICAgICAIAAAAAAAAAAAAAAAAED/////////////QAAAAAAAAAAAAAAAAED/////////////QAAAAAAAAAAAAAAAACCAgICAgICA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAABBAQEBAQEBA////QAAAAAAAAAAAAAAAAED/////////////QAAAAAAAAAAAAAAAAED/////////////QAAAAAAAAAAAAAAAADC/v7+/v7+/v7+/MAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMICAgAAAAAAAAAAAAAAAAAAAAAAAAAAAz////3AAAAAAAAAAAAAAAAAAAAAAAABw/////+8QAAAAAAAAAAAAAAAAAAAAABDv///v//+fAAAAAAAAAAAAAAAAAAAAAJ///79A////QAAAAAAAAAAAAAAAAAAAQP///0AAr///zwAAAAAAAAAAAAAAAAAAz///nwAAIP///3AAAAAAAAAAAAAAAABw///vIAAAAID//+8QAAAAAAAAAAAAABDv//+AAAAAABDv//+vAAAAAAAAAAAAAJ///98QAAAAAABg////QAAAAAAAAAAAQP///2AAAAAAAAAAz///3wAAAAAAAAAAz///vwAAAAAAAAAAQP///4AAAAAAAACA////QAAAAAAAAAAAAJ///+8gAAAAAABwgIBgAAAAAAAAAAAAACCAgIBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABfAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgICAgICAgICAgICAgICAgICAgEAAAAD//////////////////////////4AAAAD//////////////////////////4AAAACAgICAgICAgICAgICAgICAgICAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIO+PEAAAAAAAAAAAAAAAAAAAAAAAAAAAr///30AAAAAAAAAAAAAAAAAAAAAAAAAw//////+vEAAAAAAAAAAAAAAAAAAAAAAAII/v////72AAAAAAAAAAAAAAAAAAAAAAAAAQgO////+vAAAAAAAAAAAAAAAAAAAAAAAAAABg3/9wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBgn7//////769gEAAAAAAAAAAAAAAAUP//////////////3zAAAAAAAAAAAAAAIP/////vv7/v/////+8wAAAAAAAAAAAAAK+PQAAAAAAAIJ/////PAAAAAAAAAAAAAAAAAAAAAAAAAACP////MAAAAAAAAAAAAAAAAAAAAAAAAAAQ////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAYJ/P////////////gAAAAAAAAAAAAFDf////////////////gAAAAAAAAAAAn/////+vcEBAQEBA////gAAAAAAAAABQ////zyAAAAAAAAAA////gAAAAAAAAADP////IAAAAAAAAAAA////gAAAAAAAABD///+vAAAAAAAAAAAA////gAAAAAAAAED///+AAAAAAAAAAAAA////gAAAAAAAADD///+AAAAAAAAAAAAA////gAAAAAAAAAD////PAAAAAAAAAABg////gAAAAAAAAACv////UAAAAAAAAID/////vwAAAAAAAABA/////49AQEBw3///3////3AAAAAAAAAAYP////////////+PEN////8wAAAAAAAAAFDf////////v0AAADDP/98AAAAAAAAAAAAAMGCAgFAgAAAAAAAAMFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABiAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCAj2AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAECv////359AAAAAAAAAAAAAAP///4AQr///////////jwAAAAAAAAAAAP///4/P///vv7/v/////48AAAAAAAAAAP///+//72AAAAAAcP////9AAAAAAAAAAP/////PIAAAAAAAAHD///+vAAAAAAAAAP///+8wAAAAAAAAAADf////EAAAAAAAAP///4AAAAAAAAAAAACA////UAAAAAAAAP///4AAAAAAAAAAAABA////gAAAAAAAAP///4AAAAAAAAAAAAAQ////vwAAAAAAAP///4AAAAAAAAAAAAAA////vwAAAAAAAP///4AAAAAAAAAAAAAA////vwAAAAAAAP///4AAAAAAAAAAAAAA////vwAAAAAAAP///4AAAAAAAAAAAAAA////vwAAAAAAAP///4AAAAAAAAAAAAAA////vwAAAAAAAP///4AAAAAAAAAAAABA////gAAAAAAAAP///4AAAAAAAAAAAABw////UAAAAAAAAP///4AAAAAAAAAAAADP////EAAAAAAAAP///+8QAAAAAAAAAFD///+fAAAAAAAAAP/////fMAAAAAAAQO////8gAAAAAAAAAP///+///69wQHCv/////4AAAAAAAAAAAP///1DP////////////nwAAAAAAAAAAAP///0AQj+///////99QAAAAAAAAAAAAAAAAAAAAABBAgIBwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACCPz/////+/jyAAAAAAAAAAAAAAAAAQj/////////////+fEAAAAAAAAAAAABDP//////+/z///////3wAAAAAAAAAAAM/////PUAAAAABQn///YAAAAAAAAAAAgP///58AAAAAAAAAACCAAAAAAAAAAAAQ7///3xAAAAAAAAAAAAAAAAAAAAAAAABw////YAAAAAAAAAAAAAAAAAAAAAAAAAC////vAAAAAAAAAAAAAAAAAAAAAAAAAAD///+/AAAAAAAAAAAAAAAAAAAAAAAAADD///+PAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAAED///+AAAAAAAAAAAAAAAAAAAAAAAAAABD///+fAAAAAAAAAAAAAAAAAAAAAAAAAADv///PAAAAAAAAAAAAAAAAAAAAAAAAAACv////IAAAAAAAAAAAAAAAAAAAAAAAAABg////gAAAAAAAAAAAAAAAAAAAAAAAAAAA3////0AAAAAAAAAAAAAwAAAAAAAAAAAAUP////9wAAAAAAAAQL//MAAAAAAAAAAAAI//////75+AgJ/f////3xAAAAAAAAAAAABw///////////////vYAAAAAAAAAAAAAAAIJ//////////34AQAAAAAAAAAAAAAAAAAAAAQHCAgFAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGCPgEAAAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAHC/////348gAID//78AAAAAAAAAAAAw3//////////vYID//78AAAAAAAAAADDv/////8+/z////8///78AAAAAAAAAAM////+vIAAAACCP/////78AAAAAAAAAYP///58AAAAAAAAAYP///78AAAAAAAAA3///7xAAAAAAAAAAAJ///78AAAAAAAAw////jwAAAAAAAAAAAID//78AAAAAAACA////QAAAAAAAAAAAAID//78AAAAAAACv////EAAAAAAAAAAAAID//78AAAAAAAC/////AAAAAAAAAAAAAID//78AAAAAAAC/////AAAAAAAAAAAAAID//78AAAAAAAC/////AAAAAAAAAAAAAID//78AAAAAAAC/////AAAAAAAAAAAAAID//78AAAAAAAC/////AAAAAAAAAAAAAID//78AAAAAAACf////MAAAAAAAAAAAAID//78AAAAAAABw////YAAAAAAAAAAAAID//78AAAAAAAAg////rwAAAAAAAAAAAL///78AAAAAAAAAz////zAAAAAAAAAAn////78AAAAAAAAAYP///98gAAAAAACf/////78AAAAAAAAAAL/////vn2BAgN///7///78AAAAAAAAAABDP////////////YFD//78AAAAAAAAAAAAQj+///////78wAED//78AAAAAAAAAAAAAABBAgIBgIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgj8/////fn0AAAAAAAAAAAAAAAAAAAID///////////+vEAAAAAAAAAAAAAAAn//////fv8//////zxAAAAAAAAAAAACA////v0AAAAAQj////48AAAAAAAAAACD///+fAAAAAAAAAHD///8wAAAAAAAAAJ///98QAAAAAAAAAAC///+fAAAAAAAAEP///3AAAAAAAAAAAABg///vAAAAAAAAUP///zAAAAAAAAAAAAAg////MAAAAAAAgP///wAAAAAAAAAAAAAA////QAAAAAAAv///70BAQEBAQEBAQEBA////gAAAAAAAv///////////////////////gAAAAAAAv///////////////////////gAAAAAAAv///34CAgICAgICAgICAgICAIAAAAAAAn////wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///xAAAAAAAAAAAAAAAAAAAAAAAAAAMP///2AAAAAAAAAAAAAAAAAAAAAAAAAAAN///78AAAAAAAAAAAAAAAAAAAAAAAAAAGD///9wAAAAAAAAAAAAIAAAAAAAAAAAAADP////jxAAAAAAABCA74AAAAAAAAAAAAAw7////++fgICAr/////9AAAAAAAAAAAAAMM///////////////58QAAAAAAAAAAAAABCA3////////++fQAAAAAAAAAAAAAAAAAAAADBggIBwQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwgK+/v6+AUBAAAAAAAAAAAAAAAAAAML////////////9wAAAAAAAAAAAAAABQ7/////////////9AAAAAAAAAAAAAACDv///vgEAgEEBgn88AAAAAAAAAAAAAAJ////8wAAAAAAAAAAAAAAAAAAAAAAAAAN///58AAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAED///////////////////+/AAAAAAAAAED///////////////////+PAAAAAAAAADC/v7+/v////9+/v7+/v79gAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEGCfAAAAAAAAAAAAAAAAAAAAADBAUIC/////IAAAAAAAAAAAII/P////////////////cAAAAAAAABCP////////////////77+vYAAAAAAAEM/////fj4CAr+//73AAAAAAAAAAAAAAr////3AAAAAAABDP//+fAAAAAAAAAAAw////gAAAAAAAAAAQ7///cAAAAAAAAACP////EAAAAAAAAAAAn///3wAAAAAAAAC////PAAAAAAAAAAAAgP///yAAAAAAAAC///+/AAAAAAAAAAAAgP///0AAAAAAAAC////PAAAAAAAAAAAAgP///yAAAAAAAABw////IAAAAAAAAAAAr///7wAAAAAAAAAg////jwAAAAAAAAAw////nwAAAAAAAAAAgP///4AAAAAAADDP///vEAAAAAAAAAAAAID////vr4CAv////+8wAAAAAAAAAAAAAACA////////////vzAAAAAAAAAAAAAAAGD//5+Av7+/v4AwAAAAAAAAAAAAAAAAIP//3wAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//7zAAAAAAAAAAAAAAAAAAAAAAAAAAQP/////Pv7+/v7+/j1AAAAAAAAAAAAAAAJ/////////////////fUAAAAAAAAAAAAACA7////////////////58AAAAAAAAAAAAAADBAQEBAQEBwr/////9gAAAAAAAAAAAAAAAAAAAAAAAAADDv///fAAAAAAAAAAAAAAAAAAAAAAAAAACP////AAAAADC/v48AAAAAAAAAAAAAAABQ////EAAAAED///8AAAAAAAAAAAAAAACf////AAAAAAD///9wAAAAAAAAAAAAAGD///+fAAAAAACf////v3BAQAAgQECAz////+8gAAAAAAAQz///////////////////3zAAAAAAAAAAEJ///////////////9+AEAAAAAAAAAAAAAAQUICPv7+/r4BwMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBwgEAAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAAAAAAAAAAAAAAAAAAAAAAAAAAP///4AAACCPz////8+AEAAAAAAAAAAAAP///4AAgP//////////zxAAAAAAAAAAAP///4Cf///vv7+//////58AAAAAAAAAAP///+///4AQAAAAIM////8gAAAAAAAAAP/////vMAAAAAAAAED///9gAAAAAAAAAP///+8wAAAAAAAAAAD///+AAAAAAAAAAP///48AAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQn79wAAAAAAAAAAAAAAAAAAAAAAAAAADP////gAAAAAAAAAAAAAAAAAAAAAAAADD/////vwAAAAAAAAAAAAAAAAAAAAAAABD/////rwAAAAAAAAAAAAAAAAAAAAAAAABg///fMAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYICAgICAgICAgAAAAAAAAAAAAAAAAAAAv////////////wAAAAAAAAAAAAAAAAAAv////////////wAAAAAAAAAAAAAAAAAAMEBAQEBAn////wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAv7+/v7+/3////7+/v7+/vzAAAAAAAAAA/////////////////////0AAAAAAAAAA/////////////////////0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFCvr0AAAAAAAAAAAAAAAAAAAAAAAAAAQP////8gAAAAAAAAAAAAAAAAAAAAAAAAgP////+AAAAAAAAAAAAAAAAAAAAAAAAAYP////9gAAAAAAAAAAAAAAAAAAAAAAAAAL///58AAAAAAAAAAAAAAAAAAAAAAAAAAAAgEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAggICAgICAgICAgICAIAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAAAQQEBAQEBAQEBw////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////MAAAAAAAAAAAAAAAAAAAAAAAAABw////AAAAAAAAAAAAAAAAAAAAAAAAAACf///PAAAAAAAAAAAAAAAAAAAAAAAAABD///+PAAAAAAAAAAAAAAAAAAAAAAAAAK////8gAAAAAAAAAAAAAAAAAAAAAAAAn////48AAAAAAAAAAAAAAAAAAAAAACCv////zxAAAAAAAAAAAAAAAAAAAAAwn+/////PEAAAAAAAAAAAAAAAABBQj8///////48AAAAAAAAAAAAAAAAAEP////////+fIAAAAAAAAAAAAAAAAAAAAN////+/cBAAAAAAAAAAAAAAAAAAAAAAAHCAQBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABrAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCAgI8AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAAAAAAAAAAAAAAAAAL///78AAAAAAAAAAABQgICAcAAAAAAAAL///78AAAAAAAAAAGD////vMAAAAAAAAL///78AAAAAAAAAYP///+8wAAAAAAAAAL///78AAAAAAABg////7zAAAAAAAAAAAL///78AAAAAAFD////vMAAAAAAAAAAAAL///78AAAAAMO///+8wAAAAAAAAAAAAAL///78AAAAw7///7zAAAAAAAAAAAAAAAL///78AADDv///vMAAAAAAAAAAAAAAAAL///78AMO///+8wAAAAAAAAAAAAAAAAAL///78w7///7zAAAAAAAAAAAAAAAAAAAL///7+P////zxAAAAAAAAAAAAAAAAAAAL///78An////78AAAAAAAAAAAAAAAAAAL///78AAL////+fAAAAAAAAAAAAAAAAAL///78AABDP////nwAAAAAAAAAAAAAAAL///78AAAAQz////4AAAAAAAAAAAAAAAL///78AAAAAMO////9gAAAAAAAAAAAAAL///78AAAAAADDv////YAAAAAAAAAAAAL///78AAAAAAABA/////zAAAAAAAAAAAL///78AAAAAAAAAYP///+8wAAAAAAAAAL///78AAAAAAAAAAGD////vMAAAAAAAAL///78AAAAAAAAAAACf////3xAAAAAAAL///78AAAAAAAAAAAAAn////88QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAQEBAQEBAQEAAAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAP////////////8AAAAAAAAAAAAAAAAAAICAgICAgL////8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8AAAAAAAAAAAAAAAAAAAAAAAAAAHD///8QAAAAAAAAAAAAAAAAAAAAAAAAADD///+PAAAAAAAAEAAAAAAAAAAAAAAAAAC/////z4CAgJ/fYAAAAAAAAAAAAAAAAAAw7///////////zwAAAAAAAAAAAAAAAAAAIL/////////vnwAAAAAAAAAAAAAAAAAAAAAgYICAYEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAG0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAYAAQgO///68wAAAwr///34AAAAAAAP//3xDP///////vEGD///////+PAAAAAP///6//77+/////n///37/P////EAAAAP////+vEAAAj/////9wAAAA7///UAAAAP///78AAAAAcP///3AAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAP///0AAAAAAQP///wAAAAAAv///gAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABuAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAgAAAACCPz////8+AEAAAAAAAAAAAAP///zAQj///////////zxAAAAAAAAAAAP///1DP///vv7+//////58AAAAAAAAAAP///+///4AQAAAAIN////8gAAAAAAAAAP/////vMAAAAAAAAGD///9gAAAAAAAAAP///+8wAAAAAAAAACD///+AAAAAAAAAAP///48AAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAP///4AAAAAAAAAAAAD///+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUK/v////359AAAAAAAAAAAAAAAAAACC/////////////rxAAAAAAAAAAAAAAMO//////z7/f/////88QAAAAAAAAAAAQ3////4AQAAAAIL////+PAAAAAAAAAACA////YAAAAAAAAAC/////QAAAAAAAAADv//+/AAAAAAAAAAAg////nwAAAAAAAFD///9gAAAAAAAAAAAAr///7wAAAAAAAI////8QAAAAAAAAAAAAcP///0AAAAAAAL///98AAAAAAAAAAAAAQP///3AAAAAAAO///78AAAAAAAAAAAAAQP///4AAAAAAAP///78AAAAAAAAAAAAAAP///4AAAAAAAP///78AAAAAAAAAAAAAAP///4AAAAAAAP///78AAAAAAAAAAAAAIP///4AAAAAAAM///78AAAAAAAAAAAAAQP///4AAAAAAAL////8AAAAAAAAAAAAAYP///0AAAAAAAHD///8wAAAAAAAAAAAAj////xAAAAAAACD///+PAAAAAAAAAAAA7///rwAAAAAAAAC////vIAAAAAAAAACA////YAAAAAAAAABA////zyAAAAAAAGD///+/AAAAAAAAAAAAj////++fgFCAz////+8gAAAAAAAAAAAAAI//////////////3zAAAAAAAAAAAAAAAABAv////////++AEAAAAAAAAAAAAAAAAAAAACBQgIBwQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgICAAAAAUK/v///vn0AAAAAAAAAAAAAA////QBC///////////+PAAAAAAAAAAAA////UM///++/v+//////gAAAAAAAAAAA////7//vYAAAABCP/////yAAAAAAAAAA/////88gAAAAAAAAj////48AAAAAAAAA////7zAAAAAAAAAAEO///98AAAAAAAAA////gAAAAAAAAAAAAK////8gAAAAAAAA////gAAAAAAAAAAAAHD///9QAAAAAAAA////gAAAAAAAAAAAAED///+AAAAAAAAA////gAAAAAAAAAAAAED///+AAAAAAAAA////gAAAAAAAAAAAABD///+AAAAAAAAA////gAAAAAAAAAAAAAD///+AAAAAAAAA////gAAAAAAAAAAAACD///+AAAAAAAAA////gAAAAAAAAAAAAED///+AAAAAAAAA////gAAAAAAAAAAAAGD///9QAAAAAAAA////gAAAAAAAAAAAAI////8gAAAAAAAA////gAAAAAAAAAAAAN///98AAAAAAAAA////7zAAAAAAAAAAcP///4AAAAAAAAAA/////+9AAAAAAABg////7xAAAAAAAAAA////////z4CAgM//////YAAAAAAAAAAA////j8////////////+AAAAAAAAAAAAA////gBCA7///////31AAAAAAAAAAAAAA////gAAAEECAgHAwAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAA////gAAAAAAAAAAAAAAAAAAAAAAAAAAAr4BwIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABxAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCAz////8+AEAAggIBgAAAAAAAAAAAAMN//////////71BA//+/AAAAAAAAAAAw7/////+/v8/////P//+/AAAAAAAAAADP////jxAAAAAgr/////+/AAAAAAAAAGD///+fAAAAAAAAAID///+/AAAAAAAAAM///+8QAAAAAAAAAADP//+/AAAAAAAAIP///48AAAAAAAAAAAC///+/AAAAAAAAYP///1AAAAAAAAAAAAC///+/AAAAAAAAgP///zAAAAAAAAAAAAC///+/AAAAAAAAv////wAAAAAAAAAAAAC///+/AAAAAAAAv////wAAAAAAAAAAAAC///+/AAAAAAAAv////wAAAAAAAAAAAAC///+/AAAAAAAAv////wAAAAAAAAAAAAC///+/AAAAAAAAr////xAAAAAAAAAAAAC///+/AAAAAAAAgP///0AAAAAAAAAAAAC///+/AAAAAAAAYP///3AAAAAAAAAAAAC///+/AAAAAAAAIP///78AAAAAAAAAABDf//+/AAAAAAAAAN////9AAAAAAAAAAJ////+/AAAAAAAAAGD////fMAAAAAAQv/////+/AAAAAAAAAADP/////5+AgJ/v/+/f//+/AAAAAAAAAAAw7///////////7zC///+/AAAAAAAAAAAAEJ////////+/IAC///+/AAAAAAAAAAAAAAAgUICAYCAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAAwcICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABggICAgIAwAAAAEIDP////748AAAAAAAC///////+AAAAw7////////78AAAAAAAC///////+PADDv/////////58AAAAAAAAAAABA//+/EN///79gQID//4AAAAAAAAAAAABA///PgP//cAAAAID//4AAAAAAAAAAAABA////7/9wAAAAAID//4AAAAAAAAAAAABA/////78AAAAAAID//1AAAAAAAAAAAABA/////0AAAAAAAGC/vzAAAAAAAAAAAABA////vwAAAAAAAAAAAAAAAAAAAAAAAABA////YAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAAAAAABA////QAAAAAAAAAAAAAAAAAAAAACPv7/P////z7+/v2AAAAAAAAAAAAAAAAC//////////////4AAAAAAAAAAAAAAAAC//////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGCv3/////+/j0AAAAAAAAAAAAAAAABQ3//////////////fUAAAAAAAAAAAAGD/////77+/v8///////1AAAAAAAAAAEO///+9QAAAAAAAQYM//vwAAAAAAAAAAYP///0AAAAAAAAAAAABgIAAAAAAAAAAAgP///wAAAAAAAAAAAAAAAAAAAAAAAAAAgP///zAAAAAAAAAAAAAAAAAAAAAAAAAAUP///88gAAAAAAAAAAAAAAAAAAAAAAAAAN//////n1AAAAAAAAAAAAAAAAAAAAAAADDf////////r3AgAAAAAAAAAAAAAAAAAAAQj+//////////r0AAAAAAAAAAAAAAAAAAABBgr+////////+fEAAAAAAAAAAAAAAAAAAAAABAj9//////rwAAAAAAAAAAAAAAAAAAAAAAAABg7////1AAAAAAAAAAAAAAAAAAAAAAAAAAYP///58AAAAAAAAAAAAAAAAAAAAAAAAAAP///78AAAAAAAAAAAAAAAAAAAAAAAAAAP///78AAAAAAAAAEIAAAAAAAAAAAAAAQP///58AAAAAAAAQz//PQAAAAAAAAABA7////0AAAAAAAACP/////9+fgICAgM//////nwAAAAAAAAAAgO////////////////+fAAAAAAAAAAAAACCP3///////////r0AAAAAAAAAAAAAAAAAAADBQgICAYEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABgr7+vAAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAYICAgIDf///fgICAgICAgAAAAAAAAAAAv////////////////////wAAAAAAAAAAv///////////////////vwAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAAC///+/AAAAAAAAAAAAAAAAAAAAAAAAAACf///fAAAAAAAAAAAAAAAAAAAAAAAAAABQ////jwAAAAAAAEAAAAAAAAAAAAAAAAAAz////8+AgICPz/+AAAAAAAAAAAAAAAAAIN/////////////vEAAAAAAAAAAAAAAAABCf/////////89gAAAAAAAAAAAAAAAAAAAAEECAgIBAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAdQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAgIBAAAAAAAAAAAAAgICAQAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAAD///+AAAAAAAAAAAAA////gAAAAAAAAADP//+PAAAAAAAAAAAg////gAAAAAAAAAC////PAAAAAAAAABDP////gAAAAAAAAACP////QAAAAAAAQN//////gAAAAAAAAAAw////749AQHC////Pz///gAAAAAAAAAAAj////////////88Qj///gAAAAAAAAAAAAIDv///////fYAAAgP//gAAAAAAAAAAAAAAQQICAYDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCAgIAgAAAAAAAAAAAAAABAgICAAAAAACD///+PAAAAAAAAAAAAAADP//+vAAAAAAC////fAAAAAAAAAAAAACD///9gAAAAAABg////MAAAAAAAAAAAAHD//+8QAAAAAAAQ////jwAAAAAAAAAAAM///58AAAAAAAAAr///3wAAAAAAAAAAIP///1AAAAAAAAAAUP///zAAAAAAAAAAcP//3wAAAAAAAAAAAO///48AAAAAAAAAz///jwAAAAAAAAAAAJ///98AAAAAAAAg////MAAAAAAAAAAAADD///8wAAAAAABw///PAAAAAAAAAAAAAADf//+PAAAAAADP//9wAAAAAAAAAAAAAACP///fAAAAACD///8gAAAAAAAAAAAAAAAg////MAAAAHD//68AAAAAAAAAAAAAAAAAz///jwAAAM///2AAAAAAAAAAAAAAAAAAcP//3wAAIP//7xAAAAAAAAAAAAAAAAAAEP///0AAcP//nwAAAAAAAAAAAAAAAAAAAK///58Az///UAAAAAAAAAAAAAAAAAAAAGD//+8g///fAAAAAAAAAAAAAAAAAAAAAADv//+///+PAAAAAAAAAAAAAAAAAAAAAACf//////8wAAAAAAAAAAAAAAAAAAAAAABQ/////88AAAAAAAAAAAAAAAAAAAAAAAAA3////3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB3AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIICAgCAAAAAAAAAAAAAAAAAAAABAgIBgEP///3AAAAAAAAAAAAAAAAAAAACf//+/AO///4AAAAAAAAAAAAAAAAAAAAC///+AAL///78AAAAAAIC/v79wAAAAAADv//9gAID//88AAAAAAM////+/AAAAAAD///8wAFD///8AAAAAAP//////AAAAAED///8AADD///8gAAAAQP//3///MAAAAFD//88AAAD///9AAAAAgP//gP//YAAAAID//68AAAC///9gAAAAr///IP//jwAAAJ///4AAAACf//+AAAAA3//PAP//vwAAAL///0AAAABw//+vAAAQ//+fAL///wAAAO///yAAAABA//+/AABA//9wAJ///zAAAP///wAAAAAQ////AACA//9AAHD//1AAMP//vwAAAAAA3///EACv//8AAED//4AAQP//nwAAAAAAv///QADf/98AABD//78AgP//cAAAAAAAgP//YCD//68AAADv/+8Aj///QAAAAAAAUP//gFD//4AAAAC///8gv///EAAAAAAAMP//r4D//0AAAACA//9Q3//vAAAAAAAAAP//v7///xAAAABg//+A//+/AAAAAAAAAL///+//3wAAAABA///v//+PAAAAAAAAAJ//////vwAAAAAA//////9gAAAAAAAAAHD/////gAAAAAAAz/////9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECAgIBQAAAAAAAAAAAAcICAgBAAAAAAABDv///vEAAAAAAAAABA////jwAAAAAAAABQ////rwAAAAAAABDf///fEAAAAAAAAAAAj////0AAAAAAAID///8wAAAAAAAAAAAAEN///98QAAAAMP///4AAAAAAAAAAAAAAAED///+AAAAAz///zwAAAAAAAAAAAAAAAACP////IABw///vMAAAAAAAAAAAAAAAAAAAz///vyDv//9wAAAAAAAAAAAAAAAAAAAAMP///9///78AAAAAAAAAAAAAAAAAAAAAAID/////7yAAAAAAAAAAAAAAAAAAAAAAAADv////jwAAAAAAAAAAAAAAAAAAAAAAAGD/////7yAAAAAAAAAAAAAAAAAAAAAAIO///+///88AAAAAAAAAAAAAAAAAAAAAv///v1D///+AAAAAAAAAAAAAAAAAAACA///vIACv////MAAAAAAAAAAAAAAAADD///9wAAAQ7///zwAAAAAAAAAAAAAAEM///78AAAAAcP///48AAAAAAAAAAAAAj////yAAAAAAAL////9AAAAAAAAAAABA////gAAAAAAAACD////fEAAAAAAAABDv///PAAAAAAAAAACA////jwAAAAAAAK////8wAAAAAAAAAAAAz////1AAAAAAYP///4AAAAAAAAAAAAAAQP///+8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADCAgIAgAAAAAAAAAAAAAABQgICAAAAAACD///+PAAAAAAAAAAAAAADP//+vAAAAAACv///fAAAAAAAAAAAAACD///9gAAAAAABg////MAAAAAAAAAAAAHD///8QAAAAAAAQ////jwAAAAAAAAAAAM///58AAAAAAAAAr///3wAAAAAAAAAAIP///1AAAAAAAAAAYP///zAAAAAAAAAAcP//7wAAAAAAAAAAEO///48AAAAAAAAAz///nwAAAAAAAAAAAJ///98AAAAAAAAg////UAAAAAAAAAAAAFD///8wAAAAAABg///fAAAAAAAAAAAAAADv//+PAAAAAACv//+PAAAAAAAAAAAAAACf///fAAAAABD///8wAAAAAAAAAAAAAABA////MAAAAGD//98AAAAAAAAAAAAAAAAA3///cAAAAK///48AAAAAAAAAAAAAAAAAj///zwAAEP///zAAAAAAAAAAAAAAAAAAMP///yAAYP//zwAAAAAAAAAAAAAAAAAAAN///3AAn///cAAAAAAAAAAAAAAAAAAAAID//88A7///IAAAAAAAAAAAAAAAAAAAACD///9w///PAAAAAAAAAAAAAAAAAAAAAADP///v//9wAAAAAAAAAAAAAAAAAAAAAABw//////8QAAAAAAAAAAAAAAAAAAAAAAAg/////68AAAAAAAAAAAAAAAAAAAAAAAAAAO///2AAAAAAAAAAAAAAAAAAAAAAAAAAYP//7xAAAAAAAAAAAAAAAAAAAAAAAAAA3///gAAAAAAAAAAAAAAAAAAAAAAAAACf///vEAAAAAAAAAAAAAAAAAAAAAAAEJ////9QAAAAAAAAAAAAAAAAAAAAIFCf7////58AAAAAAAAAAAAAAAAAAAAAj///////gAAAAAAAAAAAAAAAAAAAAAAAYP///79AAAAAAAAAAAAAAAAAAAAAAAAAMI9wIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB6AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABggICAgICAgICAgICAgIAgAAAAAAAAAAC///////////////////9AAAAAAAAAAAC///////////////////9AAAAAAAAAAABggICAgICAgICAj////+8QAAAAAAAAAAAAAAAAAAAAAAAAn////0AAAAAAAAAAAAAAAAAAAAAAAABg////gAAAAAAAAAAAAAAAAAAAAAAAADDv//+/AAAAAAAAAAAAAAAAAAAAAAAAEM///+8QAAAAAAAAAAAAAAAAAAAAAAAAn////1AAAAAAAAAAAAAAAAAAAAAAAABg////jwAAAAAAAAAAAAAAAAAAAAAAADDv///PAAAAAAAAAAAAAAAAAAAAAAAAEM///+8gAAAAAAAAAAAAAAAAAAAAAAAAn////1AAAAAAAAAAAAAAAAAAAAAAAABg////jwAAAAAAAAAAAAAAAAAAAAAAADDv///PAAAAAAAAAAAAAAAAAAAAAAAAEM///+8gAAAAAAAAAAAAAAAAAAAAAAAAn////1AAAAAAAAAAAAAAAAAAAAAAAABg////jwAAAAAAAAAAAAAAAAAAAAAAADDv///PEAAAAAAAAAAAAAAAAAAAAAAAAK////////////////////9gAAAAAAAAAL////////////////////9AAAAAAAAAAL////////////////////8QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAewAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQDAAAAAAAAAAAAAAAAAAAAAAAABgr+///78AAAAAAAAAAAAAAAAAAAAAEM///////78AAAAAAAAAAAAAAAAAAAAAz////++fgGAAAAAAAAAAAAAAAAAAAABQ////jwAAAAAAAAAAAAAAAAAAAAAAAACA///PAAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAABA///fAAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAAAg////AAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAA7///QAAAAAAAAAAAAAAAAAAAAAAAAAAAv///YAAAAAAAAAAAAAAAAAAAAAAAAAAAv///gAAAAAAAAAAAAAAAAAAAAAAAAAAAj///gAAAAAAAAAAAAAAAAAAAAAAAAAAAv///gAAAAAAAAAAAAAAAAAAAAAAAAAAQ7///cAAAAAAAAAAAAAAAAAAAAAAAAFDf////IAAAAAAAAAAAAAAAAAAAj7/P/////+9gAAAAAAAAAAAAAAAAAAAAv//////vnxAAAAAAAAAAAAAAAAAAAAAAv////////78wAAAAAAAAAAAAAAAAAAAAAAAgUJ/////vEAAAAAAAAAAAAAAAAAAAAAAAAAAw////cAAAAAAAAAAAAAAAAAAAAAAAAAAAv///gAAAAAAAAAAAAAAAAAAAAAAAAAAAj///gAAAAAAAAAAAAAAAAAAAAAAAAAAAv///gAAAAAAAAAAAAAAAAAAAAAAAAAAAv///cAAAAAAAAAAAAAAAAAAAAAAAAAAA3///QAAAAAAAAAAAAAAAAAAAAAAAAAAA////QAAAAAAAAAAAAAAAAAAAAAAAAAAQ////EAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA///vAAAAAAAAAAAAAAAAAAAAAAAAAABw//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAABg////MAAAAAAAAAAAAAAAAAAAAAAAAAAQ7////59gQDAAAAAAAAAAAAAAAAAAAAAAMO///////78AAAAAAAAAAAAAAAAAAAAAACCf/////78AAAAAAAAAAAAAAAAAAAAAAAAAAEBwgGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAQP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAEEBAMAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEAgAAAAAAAAAAAAAAAAAAAAAAAAAAAA////769gAAAAAAAAAAAAAAAAAAAAAAAA////////vxAAAAAAAAAAAAAAAAAAAAAAgICv7////78AAAAAAAAAAAAAAAAAAAAAAAAAAJ////8wAAAAAAAAAAAAAAAAAAAAAAAAAADf//+AAAAAAAAAAAAAAAAAAAAAAAAAAAC///+AAAAAAAAAAAAAAAAAAAAAAAAAAAC///9gAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAACD///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED//88AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//68AAAAAAAAAAAAAAAAAAAAAAAAAAJ///4AAAAAAAAAAAAAAAAAAAAAAAAAAAK///68AAAAAAAAAAAAAAAAAAAAAAAAAAID///8wAAAAAAAAAAAAAAAAAAAAAAAAACD////vYBAAAAAAAAAAAAAAAAAAAAAAAABQ7//////fv48AAAAAAAAAAAAAAAAAAAAAEHDf/////78AAAAAAAAAAAAAAAAAAAAwr////////78AAAAAAAAAAAAAAAAAABDv////n1AwAAAAAAAAAAAAAAAAAAAAAHD///9gAAAAAAAAAAAAAAAAAAAAAAAAAK///88AAAAAAAAAAAAAAAAAAAAAAAAAAK///4AAAAAAAAAAAAAAAAAAAAAAAAAAAID//58AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAFD//78AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAADD///8AAAAAAAAAAAAAAAAAAAAAAAAAAAD///8wAAAAAAAAAAAAAAAAAAAAAAAAAAD///9AAAAAAAAAAAAAAAAAAAAAAAAAAADP//9QAAAAAAAAAAAAAAAAAAAAAAAAAAC///+AAAAAAAAAAAAAAAAAAAAAAAAAAAC///+AAAAAAAAAAAAAAAAAAAAAAAAAAFD///9QAAAAAAAAAAAAAAAAAAAAQEBgn////98AAAAAAAAAAAAAAAAAAAAA////////7zAAAAAAAAAAAAAAAAAAAAAA/////++fEAAAAAAAAAAAAAAAAAAAAAAAgIBwQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAfgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACBggFAQAAAAAAAAAAAAAAAAAAAAAAAQv///////gAAAAAAAAAAAn0AAAAAAACDv/////////88QAAAAAABw//+PAAAAAM////+fgM/////PEAAAAGD///9AAAAAgP//7zAAAACA////73BQn////48AAAAA7///MAAAAAAAYP//////////zxAAAAAAIJ+AAAAAAAAAAEDf//////+vEAAAAAAAAAAAAAAAAAAAAAAQYI+vgDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAALcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIICAcBAAAAAAAAAAAAAAAAAAAAAAAABg/////88QAAAAAAAAAAAAAAAAAAAAABDv//////+fAAAAAAAAAAAAAAAAAAAAAED////////fAAAAAAAAAAAAAAAAAAAAAED////////fAAAAAAAAAAAAAAAAAAAAABDv//////+fAAAAAAAAAAAAAAAAAAAAAABg/////88QAAAAAAAAAAAAAAAAAAAAAAAAIICAYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADXAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMI8AAAAAAAAAABCPAAAAAAAAAAAAAAAw7/+fAAAAAAAAEM//nwAAAAAAAAAAAACf////nwAAAAAQz////0AAAAAAAAAAAAAQz////58AABDP////YAAAAAAAAAAAAAAAEM////+fEM////9gAAAAAAAAAAAAAAAAABDP////7////2AAAAAAAAAAAAAAAAAAAAAQz///////YAAAAAAAAAAAAAAAAAAAAAAAIP////+/AAAAAAAAAAAAAAAAAAAAAAAQz///////nwAAAAAAAAAAAAAAAAAAABDP////3////58AAAAAAAAAAAAAAAAAEM////9gEM////+fAAAAAAAAAAAAAAAQz////2AAABDP////nwAAAAAAAAAAAACf////YAAAAAAQz////0AAAAAAAAAAAAAQz/9gAAAAAAAAEM//YAAAAAAAAAAAAAAAEFAAAAAAAAAAABBQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA6QAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAFBAAAAAAAAAAAAAAAAAAAAAAAAAAAAwv//PAAAAAAAAAAAAAAAAAAAAAAAAEJ//////YAAAAAAAAAAAAAAAAAAAAACA7/////+/UAAAAAAAAAAAAAAAAAAAUN/////vnzAAAAAAAAAAAAAAAAAAAAAAgP//z2AQAAAAAAAAAAAAAAAAAAAAAAAAEJ8wAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAII/P////359AAAAAAAAAAAAAAAAAAACA////////////rxAAAAAAAAAAAAAAAJ//////37/P/////88QAAAAAAAAAAAAgP///79AAAAAEI////+PAAAAAAAAAAAg////nwAAAAAAAABw////MAAAAAAAAACf///fEAAAAAAAAAAAv///nwAAAAAAABD///9wAAAAAAAAAAAAYP//7wAAAAAAAFD///8wAAAAAAAAAAAAIP///zAAAAAAAID///8AAAAAAAAAAAAAAP///0AAAAAAAL///+9AQEBAQEBAQEBAQP///4AAAAAAAL///////////////////////4AAAAAAAL///////////////////////4AAAAAAAL///9+AgICAgICAgICAgICAgCAAAAAAAJ////8AAAAAAAAAAAAAAAAAAAAAAAAAAID///8QAAAAAAAAAAAAAAAAAAAAAAAAADD///9gAAAAAAAAAAAAAAAAAAAAAAAAAADf//+/AAAAAAAAAAAAAAAAAAAAAAAAAABg////cAAAAAAAAAAAACAAAAAAAAAAAAAAz////48QAAAAAAAQgO+AAAAAAAAAAAAAMO/////vn4CAgK//////QAAAAAAAAAAAADDP//////////////+fEAAAAAAAAAAAAAAQgN/////////vn0AAAAAAAAAAAAAAAAAAAAAwYICAcEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABMgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQEBAQEBAQBAAv////////////////////////////0AAv////////////////////////////0AAj7+/v7+/v7+/v7+/v7+/v7+/v7+/vzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA////////////////////////////////////////////////////////////////v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGCAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQDAAAAAAAAAAAAAAAAAAAAAAAAAAAGD//4AAAAAAAAAAAAAAAAAAAAAAAAAAAN///0AAAAAAAAAAAAAAAAAAAAAAAAAAYP///xAAAAAAAAAAAAAAAAAAAAAAAAAA3///zwAAAAAAAAAAAAAAAAAAAAAAAABA////nwAAAAAAAAAAAAAAAAAAAAAAAAC/////YAAAAAAAAAAAAAAAAAAAAAAAAED/////MAAAAAAAAAAAAAAAAAAAAAAAAK//////YAAAAAAAAAAAAAAAAAAAAAAAAP//////7wAAAAAAAAAAAAAAAAAAAAAAAP///////wAAAAAAAAAAAAAAAAAAAAAAAN//////3wAAAAAAAAAAAAAAAAAAAAAAADDv///vMAAAAAAAAAAAAAAAAAAAAAAAAAAQYGAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABkgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADBAMAAAAAAAAAAAAAAAAAAAAAAAAAAAn////78AAAAAAAAAAAAAAAAAAAAAAABA//////9wAAAAAAAAAAAAAAAAAAAAAACA//////+/AAAAAAAAAAAAAAAAAAAAAABw//////+fAAAAAAAAAAAAAAAAAAAAAAAQz/////9QAAAAAAAAAAAAAAAAAAAAAAAAj////98AAAAAAAAAAAAAAAAAAAAAAAAAz////2AAAAAAAAAAAAAAAAAAAAAAAAAA////3wAAAAAAAAAAAAAAAAAAAAAAAABA////YAAAAAAAAAAAAAAAAAAAAAAAAACA///vAAAAAAAAAAAAAAAAAAAAAAAAAACv//+AAAAAAAAAAAAAAAAAAAAAAAAAAADv/+8QAAAAAAAAAAAAAAAAAAAAAAAAAACAgFAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQEAQAAAAAAAwQEAAAAAAAAAAAAAAAADf//8QAAAAABDv/+8AAAAAAAAAAAAAAGD//88AAAAAAHD//78AAAAAAAAAAAAAAM///58AAAAAAN///4AAAAAAAAAAAAAAQP///2AAAAAAYP///0AAAAAAAAAAAAAAv////zAAAAAA3////xAAAAAAAAAAAABA////7wAAAABg////zwAAAAAAAAAAAACv////vwAAAADf////nwAAAAAAAAAAACD/////zxAAAED/////zxAAAAAAAAAAAHD//////48AAID//////3AAAAAAAAAAAID//////78AAK///////4AAAAAAAAAAAFD//////3AAAHD//////2AAAAAAAAAAAACf////vxAAAAC/////nwAAAAAAAAAAAAAAMIBAAAAAAAAAQIAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHSAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAQBAAAAAAAABAQBAAAAAAAAAAAAAAIM///+9gAAAAMO///+8wAAAAAAAAAAAAr//////vEAAA3//////fAAAAAAAAAAAA////////QAAA////////AAAAAAAAAAAA3///////MAAA////////AAAAAAAAAAAAYP/////fAAAAYP////+/AAAAAAAAAAAAEP////9gAAAAMP////9QAAAAAAAAAAAAQP///98AAAAAYP///98AAAAAAAAAAAAAgP///3AAAAAAj////2AAAAAAAAAAAAAAr///7xAAAAAAz///3wAAAAAAAAAAAAAA7///gAAAAAAA////YAAAAAAAAAAAAAAg///vEAAAAABA///fAAAAAAAAAAAAAABQ//+PAAAAAACA//+AAAAAAAAAAAAAAABAgIAgAAAAAABQgIAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwr+//z4AQAAAAAAAAAAAAAAAAAAAAAGD////////PEAAAAAAAAAAAAAAAAAAAIO//////////nwAAAAAAAAAAAAAAAAAAcP///////////xAAAAAAAAAAAAAAAAAAr////////////0AAAAAAAAAAAAAAAAAAr////////////0AAAAAAAAAAAAAAAAAAcP///////////xAAAAAAAAAAAAAAAAAAEO//////////nwAAAAAAAAAAAAAAAAAAAFD////////PEAAAAAAAAAAAAAAAAAAAAAAwn+//z4AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACP//+/EAAAAGDv/88wAAAAMN//71AAAHD/////nwAAMP////+/AAAQ7////+8QAL//////vwAAgP//////AABA//////9AAI//////rwAAYP/////vAAAg//////8wACDv///vMAAAAM////9gAAAAj////58AAAAQYHAgAAAAAABQgDAAAAAAAECAUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP///////////////////////////////////////////////////////////////////////////////////////////////0BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIlAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAMJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAED/////////////////AAAAAAAAAAAAAED/////////////////AAAAAAAAAAAAAED/////////////////AAAAAAAAAAAAAED///9AQEBAQEBAQEBAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAECUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA//////////////////8AAAAAAAAAAAAA//////////////////8AAAAAAAAAAAAA//////////////////8AAAAAAAAAAAAAQEBAQEBAQEBAQHD///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAABQlAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP////////////////8AAAAAAAAAAAAAQP////////////////8AAAAAAAAAAAAAQP////////////////8AAAAAAAAAAAAAEEBAQEBAQEBAQEBAQEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYJQAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAD//////////////////wAAAAAAAAAAAAD//////////////////wAAAAAAAAAAAAD//////////////////wAAAAAAAAAAAABAQEBAQEBAQEBAQEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHCUAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA/////////////////wAAAAAAAAAAAABA/////////////////wAAAAAAAAAAAABA/////////////////wAAAAAAAAAAAABA////QEBAQEBAQEBAQAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAACQlAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAP//////////////////AAAAAAAAAAAAAP//////////////////AAAAAAAAAAAAAP//////////////////AAAAAAAAAAAAAEBAQEBAQEBAQEBw////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAsJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA////////////////////////////////////////////////////////////////////////////////////////////////QEBAQEBAQEBAQHD///9AQEBAQEBAQEBAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAAAAAAAAAAAAAAAED///8AAAAAAAAAAAAANCUAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAAAAAAAAAAAAAABA////AAAAAAAAAAAAAP///////////////////////////////////////////////////////////////////////////////////////////////0BAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADwlAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////9AQEBAQEBAQEBAcP///0BAQEBAQEBAQEAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAAAAAAAAAAAAAAAAQP///wAAAAAAAAAAAABQJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBA////////////////////////////////////////////////////////////////////////////////////////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAv7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/////////////////////////////////////////////////////////////////gICAgICAgICAgICAgICAgICAgICAgICAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUSUAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAFQlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIEBAQEBAQEBAQEBAQEBAQEAAAAAAAAAAgP////////////////////8AAAAAAAAAgP////////////////////8AAAAAAAAAgP////////////////////8AAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAAAAAAAAAAAAAAAAAAAAAAAAgP//vwAAAL+/v7+/v7+/v78AAAAAAAAAgP//vwAAAP////////////8AAAAAAAAAgP//vwAAAP////////////8AAAAAAAAAgP//vwAAAP///5+AgICAgIAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAAAAAAAAAAAAgP//vwAAAP///0AAAAAAAABXJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQEAwAAAAAAAAv/////////////////////+/AAAAAAAAv/////////////////////+/AAAAAAAAv/////////////////////+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAj7+/v7+/v7+/v78wAACA//+/AAAAAAAAv/////////////9AAACA//+/AAAAAAAAv/////////////9AAACA//+/AAAAAAAAYICAgICAgJ////9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAAAAAAAAAAAAAAED///9AAACA//+/AAAAWiUAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////QAAAAAAAAAAAAAAAAACA//+/AAAA////cEBAQEBAQAAAAAAAAACA//+/AAAA/////////////wAAAAAAAACA//+/AAAA/////////////wAAAAAAAACA//+/AAAA/////////////wAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA//+/AAAAAAAAAAAAAAAAAAAAAAAAAACA///vv7+/v7+/v7+/v7+/vwAAAAAAAACA/////////////////////wAAAAAAAACA/////////////////////wAAAAAAAABAgICAgICAgICAgICAgICAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAF0lAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAAAAAAAAAAQP///0AAAID//78AAAAAAAAwQEBAQEBAcP///0AAAID//78AAAAAAAC//////////////0AAAID//78AAAAAAAC//////////////0AAAID//78AAAAAAAC//////////////0AAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAAAAAAAAAAAAAAAAAAAAAID//78AAAAAAACPv7+/v7+/v7+/v7+/v9///78AAAAAAAC//////////////////////78AAAAAAAC//////////////////////78AAAAAAABggICAgICAgICAgICAgICAgGAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAJQAA////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////v7+/v7+/v7+/v7+/v7+/v7+/v7+/v7+/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAhCUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQP///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////4glAAD///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////+MJQAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAAAAAAABA////////////////QAAAAAAAkCUAAAAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////wAAAAAAAAAAAAAAAL///////////////5ElAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL+/AACPvzAAj78wAI+/YABgv2AAYL8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAEBAAAAwQBAAMEAQADBAIAAgQCAAIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL+/AACPvzAAj78wAI+/YABgv2AAYL8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAICAAABggCAAYIAgAGCAQABAgEAAQIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAAABggCAAYIAgAGCAQABAgEAAQIAAAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAICAAABggCAAYIAgAGCAQABAgEAAQIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAICAAABggCAAYIAgAGCAQABAgEAAQIAAAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAL+/AACPvzAAj78wAI+/YABgv2AAYL8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEBAAAAwQBAAMEAQADBAIAAgQCAAIEAAAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAL+/AACPvzAAj78wAI+/YABgv2AAYL8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAEBAAAAwQBAAMEAQADBAIAAgQCAAIEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAL+/AACPvzAAj78wAI+/YABgv2AAYL8AAP//AAC//0AAv/9AAL//gACA/4AAgP8AAP//AAC//0AAv/9AAL//gACA/4AAgP+SJQAA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAQEC/v0BAn79gQJ+/YECfv4BAgL+AQIC/AAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/v79AQL+/YECfv2BAn79gQIC/gECAv4BA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAQEC/v0BAn79gQJ+/YECfv4BAgL+AQIC/AAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/gICAgICAgICAgICAgICAgICAgICAgICA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAgICAgICAgICAgICAgICAgICAgICAgICAAAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/gICAgICAgICAgICAgICAgICAgICAgICA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAgICAgICAgICAgICAgICAgICAgICAgICAAAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/QEC/v0BAn79gQJ+/YECfv4BAgL+AQIC///8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAv79AQL+/YECfv2BAn79gQIC/gECAv4BAAAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/QEC/v0BAn79gQJ+/YECfv4BAgL+AQIC///8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAAAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID///8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAAAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/v79AQL+/YECfv2BAn79gQIC/gECAv4BA//8AAP//QAC//0AAv/9AAID/gACA/4AA//8AAP//QAC//0AAv/9AAID/gACA/4AAQEC/v0BAn79gQJ+/YECfv4BAgL+AQIC/AAD//wAAv/9AAL//QAC//4AAgP+AAID/AAD//wAAv/9AAL//QAC//4AAgP+AAID/kyUAAP//////////////////////////////////////////////////////////////////QED//3BAz/9wQM//cECf/59An/+fQP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//v7///8+/7//Pv+//z7/f/9+/3//fv///////////////////////////////////////////////////////////////////QED//3BAz/9wQM//cECf/59An/+fQP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//gID//5+A3/+fgN//n4C//7+Av/+/gP//////////////////////////////////////////////////////////////////gID//5+A3/+fgN//n4C//7+Av/+/gP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//gID//5+A3/+fgN//n4C//7+Av/+/gP//////////////////////////////////////////////////////////////////gID//5+A3/+fgN//n4C//7+Av/+/gP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//QED//3BAz/9wQM//cECf/59An/+fQP//////////////////////////////////////////////////////////////////v7///8+/7//Pv+//z7/f/9+/3//fv///AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//QED//3BAz/9wQM//cECf/59An/+fQP//////////////////////////////////////////////////////////////////////////////////////////////////AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//////////////////////////////////////////////////////////////////////////////////////////////////AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//v7///8+/7//Pv+//z7/f/9+/3//fv///////////////////////////////////////////////////////////////////QED//3BAz/9wQM//cECf/59An/+fQP//AAD//0AAv/9AAL//QACA/4AAgP+AAP//AAD//0AAv/9AAL//QACA/4AAgP+AAKAlAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMEBAQEBAQEBAQEBAQEBAQEBAQEBAQCAAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAv////////////////////////////4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADPJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgQAAAAAAAAAAAAAAAAAAAAAAAABBwz//////vr2AAAAAAAAAAAAAAAAAAgO/////////////fQAAAAAAAAAAAABDP/////////////////4AAAAAAAAAAEM////////////////////+AAAAAAAAAv///////////////////////UAAAAABg////////////////////////7xAAAADf/////////////////////////3AAADD//////////////////////////98AAID///////////////////////////8gAL////////////////////////////9AAL////////////////////////////9gAL////////////////////////////9QAK////////////////////////////9AAID///////////////////////////8QADD//////////////////////////88AAAC//////////////////////////2AAAABA////////////////////////3wAAAAAAn///////////////////////QAAAAAAAEM////////////////////9gAAAAAAAAABCv////////////////72AAAAAAAAAAAAAAYN////////////+/IAAAAAAAAAAAAAAAAABgn9////+/jzAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 14: const FALLBACK_GLYPH = makeFallbackGlyph() 15: function makeFallbackGlyph(): Uint8Array { 16: const g = new Uint8Array(GLYPH_BYTES) 17: for (let y = 2; y < GLYPH_H - 4; y++) { 18: for (let x = 1; x < GLYPH_W - 1; x++) { 19: const onBorder = 20: y === 2 || y === GLYPH_H - 5 || x === 1 || x === GLYPH_W - 2 21: if (onBorder && (x + y) % 2 === 0) g[y * GLYPH_W + x] = 255 22: } 23: } 24: return g 25: } 26: const FONT: Map<number, Uint8Array> = decodeFont() 27: function decodeFont(): Map<number, Uint8Array> { 28: const buf = Buffer.from(FONT_B64, 'base64') 29: const count = buf.readUInt16LE(0) 30: const map = new Map<number, Uint8Array>() 31: let off = 2 32: for (let i = 0; i < count; i++) { 33: const cp = buf.readUInt32LE(off) 34: off += 4 35: map.set(cp, buf.subarray(off, off + GLYPH_BYTES)) 36: off += GLYPH_BYTES 37: } 38: return map 39: } 40: export type AnsiToPngOptions = { 41: scale?: number 42: paddingX?: number 43: paddingY?: number 44: borderRadius?: number 45: background?: AnsiColor 46: } 47: export function ansiToPng( 48: ansiText: string, 49: options: AnsiToPngOptions = {}, 50: ): Buffer { 51: const { 52: scale = 1, 53: paddingX = 48, 54: paddingY = 48, 55: borderRadius = 16, 56: background = DEFAULT_BG, 57: } = options 58: const lines = parseAnsi(ansiText) 59: while ( 60: lines.length > 0 && 61: lines[lines.length - 1]!.every(span => span.text.trim() === '') 62: ) { 63: lines.pop() 64: } 65: if (lines.length === 0) { 66: lines.push([{ text: '', color: background, bold: false }]) 67: } 68: const cols = Math.max(1, ...lines.map(lineWidthCells)) 69: const rows = lines.length 70: const width = (cols * GLYPH_W + paddingX * 2) * scale 71: const height = (rows * GLYPH_H + paddingY * 2) * scale 72: // RGBA buffer, pre-filled with the background color. 73: const px = new Uint8Array(width * height * 4) 74: fillBackground(px, background) 75: if (borderRadius > 0) { 76: roundCorners(px, width, height, borderRadius * scale) 77: } 78: // Blit glyphs. 79: const padX = paddingX * scale 80: const padY = paddingY * scale 81: for (let row = 0; row < rows; row++) { 82: let col = 0 83: for (const span of lines[row]!) { 84: for (const ch of span.text) { 85: const cp = ch.codePointAt(0)! 86: const cellW = stringWidth(ch) 87: if (cellW === 0) continue // zero-width (combining marks, etc.) 88: const x = padX + col * GLYPH_W * scale 89: const y = padY + row * GLYPH_H * scale 90: const shade = SHADE_ALPHA[cp] 91: if (shade !== undefined) { 92: blitShade(px, width, x, y, span.color, background, shade, scale) 93: } else { 94: const glyph = FONT.get(cp) ?? FALLBACK_GLYPH 95: blitGlyph(px, width, x, y, glyph, span.color, span.bold, scale) 96: } 97: col += cellW 98: } 99: } 100: } 101: return encodePng(px, width, height) 102: } 103: /** Terminal column width of a parsed line. */ 104: function lineWidthCells(line: ParsedLine): number { 105: let w = 0 106: for (const span of line) w += stringWidth(span.text) 107: return w 108: } 109: function fillBackground(px: Uint8Array, bg: AnsiColor): void { 110: for (let i = 0; i < px.length; i += 4) { 111: px[i] = bg.r 112: px[i + 1] = bg.g 113: px[i + 2] = bg.b 114: px[i + 3] = 255 115: } 116: } 117: // Modern terminals render shade chars (░▒▓█) as solid blocks with opacity, 118: // not the classic VGA dither pattern. Alpha-blend toward background for the 119: // same look. 120: const SHADE_ALPHA: Record<number, number> = { 121: 0x2591: 0.25, // ░ 122: 0x2592: 0.5, // ▒ 123: 0x2593: 0.75, // ▓ 124: 0x2588: 1.0, // █ 125: } 126: function blitShade( 127: px: Uint8Array, 128: width: number, 129: x: number, 130: y: number, 131: fg: AnsiColor, 132: bg: AnsiColor, 133: alpha: number, 134: scale: number, 135: ): void { 136: const r = Math.round(fg.r * alpha + bg.r * (1 - alpha)) 137: const g = Math.round(fg.g * alpha + bg.g * (1 - alpha)) 138: const b = Math.round(fg.b * alpha + bg.b * (1 - alpha)) 139: const cellW = GLYPH_W * scale 140: const cellH = GLYPH_H * scale 141: for (let dy = 0; dy < cellH; dy++) { 142: const rowBase = ((y + dy) * width + x) * 4 143: for (let dx = 0; dx < cellW; dx++) { 144: const i = rowBase + dx * 4 145: px[i] = r 146: px[i + 1] = g 147: px[i + 2] = b 148: } 149: } 150: } 151: /** 152: * Blit one glyph into the RGBA buffer at (x,y), scaled by `scale` 153: * (nearest-neighbor). Alpha-composites over the existing background. Bold is 154: * synthesized by boosting alpha toward opaque — a cheap approximation that 155: * reads as heavier weight without needing a second font. 156: */ 157: function blitGlyph( 158: px: Uint8Array, 159: width: number, 160: x: number, 161: y: number, 162: glyph: Uint8Array, 163: color: AnsiColor, 164: bold: boolean, 165: scale: number, 166: ): void { 167: for (let gy = 0; gy < GLYPH_H; gy++) { 168: for (let gx = 0; gx < GLYPH_W; gx++) { 169: let a = glyph[gy * GLYPH_W + gx]! 170: if (a === 0) continue 171: if (bold) a = Math.min(255, a * 1.4) 172: const inv = 255 - a 173: for (let sy = 0; sy < scale; sy++) { 174: const rowBase = ((y + gy * scale + sy) * width + x + gx * scale) * 4 175: for (let sx = 0; sx < scale; sx++) { 176: const i = rowBase + sx * 4 177: px[i] = (color.r * a + px[i]! * inv) >> 8 178: px[i + 1] = (color.g * a + px[i + 1]! * inv) >> 8 179: px[i + 2] = (color.b * a + px[i + 2]! * inv) >> 8 180: } 181: } 182: } 183: } 184: } 185: /** 186: * Zero out the alpha channel in the four corner regions outside a 187: * quarter-circle of radius `r`. Produces rounded-rect corners. 188: */ 189: function roundCorners( 190: px: Uint8Array, 191: width: number, 192: height: number, 193: r: number, 194: ): void { 195: const r2 = r * r 196: for (let dy = 0; dy < r; dy++) { 197: for (let dx = 0; dx < r; dx++) { 198: const ox = r - dx - 0.5 199: const oy = r - dy - 0.5 200: if (ox * ox + oy * oy <= r2) continue 201: // Top-left, top-right, bottom-left, bottom-right. 202: px[(dy * width + dx) * 4 + 3] = 0 203: px[(dy * width + (width - 1 - dx)) * 4 + 3] = 0 204: px[((height - 1 - dy) * width + dx) * 4 + 3] = 0 205: px[((height - 1 - dy) * width + (width - 1 - dx)) * 4 + 3] = 0 206: } 207: } 208: } 209: // --- PNG encoding ----------------------------------------------------------- 210: const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) 211: const CRC_TABLE = makeCrcTable() 212: function makeCrcTable(): Uint32Array { 213: const t = new Uint32Array(256) 214: for (let n = 0; n < 256; n++) { 215: let c = n 216: for (let k = 0; k < 8; k++) { 217: c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1 218: } 219: t[n] = c >>> 0 220: } 221: return t 222: } 223: function crc32(data: Uint8Array): number { 224: let c = 0xffffffff 225: for (let i = 0; i < data.length; i++) { 226: c = CRC_TABLE[(c ^ data[i]!) & 0xff]! ^ (c >>> 8) 227: } 228: return (c ^ 0xffffffff) >>> 0 229: } 230: function chunk(type: string, data: Uint8Array): Buffer { 231: const body = Buffer.alloc(4 + data.length) 232: body.write(type, 0, 'ascii') 233: body.set(data, 4) 234: const out = Buffer.alloc(12 + data.length) 235: out.writeUInt32BE(data.length, 0) 236: body.copy(out, 4) 237: out.writeUInt32BE(crc32(body), 8 + data.length) 238: return out 239: } 240: function encodePng(px: Uint8Array, width: number, height: number): Buffer { 241: const ihdr = Buffer.alloc(13) 242: ihdr.writeUInt32BE(width, 0) 243: ihdr.writeUInt32BE(height, 4) 244: ihdr[8] = 8 245: ihdr[9] = 6 246: ihdr[10] = 0 247: ihdr[11] = 0 248: ihdr[12] = 0 249: const stride = width * 4 250: const raw = Buffer.alloc(height * (stride + 1)) 251: for (let y = 0; y < height; y++) { 252: const dst = y * (stride + 1) 253: raw[dst] = 0 254: raw.set(px.subarray(y * stride, (y + 1) * stride), dst + 1) 255: } 256: const idat = deflateSync(raw) 257: return Buffer.concat([ 258: PNG_SIG, 259: chunk('IHDR', ihdr), 260: chunk('IDAT', idat), 261: chunk('IEND', new Uint8Array(0)), 262: ]) 263: }

File: src/utils/ansiToSvg.ts

typescript 1: import { escapeXml } from './xml.js' 2: export type AnsiColor = { 3: r: number 4: g: number 5: b: number 6: } 7: const ANSI_COLORS: Record<number, AnsiColor> = { 8: 30: { r: 0, g: 0, b: 0 }, 9: 31: { r: 205, g: 49, b: 49 }, 10: 32: { r: 13, g: 188, b: 121 }, 11: 33: { r: 229, g: 229, b: 16 }, 12: 34: { r: 36, g: 114, b: 200 }, 13: 35: { r: 188, g: 63, b: 188 }, 14: 36: { r: 17, g: 168, b: 205 }, 15: 37: { r: 229, g: 229, b: 229 }, 16: 90: { r: 102, g: 102, b: 102 }, 17: 91: { r: 241, g: 76, b: 76 }, 18: 92: { r: 35, g: 209, b: 139 }, 19: 93: { r: 245, g: 245, b: 67 }, 20: 94: { r: 59, g: 142, b: 234 }, 21: 95: { r: 214, g: 112, b: 214 }, 22: 96: { r: 41, g: 184, b: 219 }, 23: 97: { r: 255, g: 255, b: 255 }, 24: } 25: export const DEFAULT_FG: AnsiColor = { r: 229, g: 229, b: 229 } 26: export const DEFAULT_BG: AnsiColor = { r: 30, g: 30, b: 30 } 27: export type TextSpan = { 28: text: string 29: color: AnsiColor 30: bold: boolean 31: } 32: export type ParsedLine = TextSpan[] 33: export function parseAnsi(text: string): ParsedLine[] { 34: const lines: ParsedLine[] = [] 35: const rawLines = text.split('\n') 36: for (const line of rawLines) { 37: const spans: TextSpan[] = [] 38: let currentColor = DEFAULT_FG 39: let bold = false 40: let i = 0 41: while (i < line.length) { 42: if (line[i] === '\x1b' && line[i + 1] === '[') { 43: let j = i + 2 44: while (j < line.length && !/[A-Za-z]/.test(line[j]!)) { 45: j++ 46: } 47: if (line[j] === 'm') { 48: const codes = line 49: .slice(i + 2, j) 50: .split(';') 51: .map(Number) 52: let k = 0 53: while (k < codes.length) { 54: const code = codes[k]! 55: if (code === 0) { 56: currentColor = DEFAULT_FG 57: bold = false 58: } else if (code === 1) { 59: bold = true 60: } else if (code >= 30 && code <= 37) { 61: currentColor = ANSI_COLORS[code] || DEFAULT_FG 62: } else if (code >= 90 && code <= 97) { 63: currentColor = ANSI_COLORS[code] || DEFAULT_FG 64: } else if (code === 39) { 65: currentColor = DEFAULT_FG 66: } else if (code === 38) { 67: if (codes[k + 1] === 5 && codes[k + 2] !== undefined) { 68: const colorIndex = codes[k + 2]! 69: currentColor = get256Color(colorIndex) 70: k += 2 71: } else if ( 72: codes[k + 1] === 2 && 73: codes[k + 2] !== undefined && 74: codes[k + 3] !== undefined && 75: codes[k + 4] !== undefined 76: ) { 77: currentColor = { 78: r: codes[k + 2]!, 79: g: codes[k + 3]!, 80: b: codes[k + 4]!, 81: } 82: k += 4 83: } 84: } 85: k++ 86: } 87: } 88: i = j + 1 89: continue 90: } 91: const textStart = i 92: while (i < line.length && line[i] !== '\x1b') { 93: i++ 94: } 95: const spanText = line.slice(textStart, i) 96: if (spanText) { 97: spans.push({ text: spanText, color: currentColor, bold }) 98: } 99: } 100: if (spans.length === 0) { 101: spans.push({ text: '', color: DEFAULT_FG, bold: false }) 102: } 103: lines.push(spans) 104: } 105: return lines 106: } 107: /** 108: * Get color from 256-color palette 109: */ 110: function get256Color(index: number): AnsiColor { 111: // Standard colors (0-15) 112: if (index < 16) { 113: const standardColors: AnsiColor[] = [ 114: { r: 0, g: 0, b: 0 }, // 0 black 115: { r: 128, g: 0, b: 0 }, // 1 red 116: { r: 0, g: 128, b: 0 }, // 2 green 117: { r: 128, g: 128, b: 0 }, // 3 yellow 118: { r: 0, g: 0, b: 128 }, // 4 blue 119: { r: 128, g: 0, b: 128 }, // 5 magenta 120: { r: 0, g: 128, b: 128 }, // 6 cyan 121: { r: 192, g: 192, b: 192 }, // 7 white 122: { r: 128, g: 128, b: 128 }, // 8 bright black 123: { r: 255, g: 0, b: 0 }, // 9 bright red 124: { r: 0, g: 255, b: 0 }, // 10 bright green 125: { r: 255, g: 255, b: 0 }, // 11 bright yellow 126: { r: 0, g: 0, b: 255 }, // 12 bright blue 127: { r: 255, g: 0, b: 255 }, // 13 bright magenta 128: { r: 0, g: 255, b: 255 }, // 14 bright cyan 129: { r: 255, g: 255, b: 255 }, // 15 bright white 130: ] 131: return standardColors[index] || DEFAULT_FG 132: } 133: // 216 color cube (16-231) 134: if (index < 232) { 135: const i = index - 16 136: const r = Math.floor(i / 36) 137: const g = Math.floor((i % 36) / 6) 138: const b = i % 6 139: return { 140: r: r === 0 ? 0 : 55 + r * 40, 141: g: g === 0 ? 0 : 55 + g * 40, 142: b: b === 0 ? 0 : 55 + b * 40, 143: } 144: } 145: // Grayscale (232-255) 146: const gray = (index - 232) * 10 + 8 147: return { r: gray, g: gray, b: gray } 148: } 149: export type AnsiToSvgOptions = { 150: fontFamily?: string 151: fontSize?: number 152: lineHeight?: number 153: paddingX?: number 154: paddingY?: number 155: backgroundColor?: string 156: borderRadius?: number 157: } 158: /** 159: * Convert ANSI text to SVG 160: * Uses <tspan> elements within a single <text> per line so the renderer 161: * handles character spacing natively (no manual charWidth calculation) 162: */ 163: export function ansiToSvg( 164: ansiText: string, 165: options: AnsiToSvgOptions = {}, 166: ): string { 167: const { 168: fontFamily = 'Menlo, Monaco, monospace', 169: fontSize = 14, 170: lineHeight = 22, 171: paddingX = 24, 172: paddingY = 24, 173: backgroundColor = `rgb(${DEFAULT_BG.r}, ${DEFAULT_BG.g}, ${DEFAULT_BG.b})`, 174: borderRadius = 8, 175: } = options 176: const lines = parseAnsi(ansiText) 177: while ( 178: lines.length > 0 && 179: lines[lines.length - 1]!.every(span => span.text.trim() === '') 180: ) { 181: lines.pop() 182: } 183: // Estimate width based on max line length (for SVG dimensions only) 184: // For monospace fonts, character width is roughly 0.6 * fontSize 185: const charWidthEstimate = fontSize * 0.6 186: const maxLineLength = Math.max( 187: ...lines.map(spans => spans.reduce((acc, s) => acc + s.text.length, 0)), 188: ) 189: const width = Math.ceil(maxLineLength * charWidthEstimate + paddingX * 2) 190: const height = lines.length * lineHeight + paddingY * 2 191: // Build SVG - use tspan elements so renderer handles character positioning 192: let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">\n` 193: svg += ` <rect width="100%" height="100%" fill="${backgroundColor}" rx="${borderRadius}" ry="${borderRadius}"/>\n` 194: svg += ` <style>\n` 195: svg += ` text { font-family: ${fontFamily}; font-size: ${fontSize}px; white-space: pre; }\n` 196: svg += ` .b { font-weight: bold; }\n` 197: svg += ` </style>\n` 198: for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { 199: const spans = lines[lineIndex]! 200: const y = 201: paddingY + (lineIndex + 1) * lineHeight - (lineHeight - fontSize) / 2 202: // Build a single <text> element with <tspan> children for each colored segment 203: // xml:space="preserve" prevents SVG from collapsing whitespace 204: svg += ` <text x="${paddingX}" y="${y}" xml:space="preserve">` 205: for (const span of spans) { 206: if (!span.text) continue 207: const colorStr = `rgb(${span.color.r}, ${span.color.g}, ${span.color.b})` 208: const boldClass = span.bold ? ' class="b"' : '' 209: svg += `<tspan fill="${colorStr}"${boldClass}>${escapeXml(span.text)}</tspan>` 210: } 211: svg += `</text>\n` 212: } 213: svg += `</svg>` 214: return svg 215: }

File: src/utils/api.ts

typescript 1: import type Anthropic from '@anthropic-ai/sdk' 2: import type { 3: BetaTool, 4: BetaToolUnion, 5: } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 6: import { createHash } from 'crypto' 7: import { SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from 'src/constants/prompts.js' 8: import { getSystemContext, getUserContext } from 'src/context.js' 9: import { isAnalyticsDisabled } from 'src/services/analytics/config.js' 10: import { 11: checkStatsigFeatureGate_CACHED_MAY_BE_STALE, 12: getFeatureValue_CACHED_MAY_BE_STALE, 13: } from 'src/services/analytics/growthbook.js' 14: import { 15: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 16: logEvent, 17: } from 'src/services/analytics/index.js' 18: import { prefetchAllMcpResources } from 'src/services/mcp/client.js' 19: import type { ScopedMcpServerConfig } from 'src/services/mcp/types.js' 20: import { BashTool } from 'src/tools/BashTool/BashTool.js' 21: import { FileEditTool } from 'src/tools/FileEditTool/FileEditTool.js' 22: import { 23: normalizeFileEditInput, 24: stripTrailingWhitespace, 25: } from 'src/tools/FileEditTool/utils.js' 26: import { FileWriteTool } from 'src/tools/FileWriteTool/FileWriteTool.js' 27: import { getTools } from 'src/tools.js' 28: import type { AgentId } from 'src/types/ids.js' 29: import type { z } from 'zod/v4' 30: import { CLI_SYSPROMPT_PREFIXES } from '../constants/system.js' 31: import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 32: import type { Tool, ToolPermissionContext, Tools } from '../Tool.js' 33: import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' 34: import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 35: import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../tools/ExitPlanModeTool/constants.js' 36: import { TASK_OUTPUT_TOOL_NAME } from '../tools/TaskOutputTool/constants.js' 37: import type { Message } from '../types/message.js' 38: import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js' 39: import { 40: modelSupportsStructuredOutputs, 41: shouldUseGlobalCacheScope, 42: } from './betas.js' 43: import { getCwd } from './cwd.js' 44: import { logForDebugging } from './debug.js' 45: import { isEnvTruthy } from './envUtils.js' 46: import { createUserMessage } from './messages.js' 47: import { 48: getAPIProvider, 49: isFirstPartyAnthropicBaseUrl, 50: } from './model/providers.js' 51: import { 52: getFileReadIgnorePatterns, 53: normalizePatternsToPath, 54: } from './permissions/filesystem.js' 55: import { 56: getPlan, 57: getPlanFilePath, 58: persistFileSnapshotIfRemote, 59: } from './plans.js' 60: import { getPlatform } from './platform.js' 61: import { countFilesRoundedRg } from './ripgrep.js' 62: import { jsonStringify } from './slowOperations.js' 63: import type { SystemPrompt } from './systemPromptType.js' 64: import { getToolSchemaCache } from './toolSchemaCache.js' 65: import { windowsPathToPosixPath } from './windowsPaths.js' 66: import { zodToJsonSchema } from './zodToJsonSchema.js' 67: type BetaToolWithExtras = BetaTool & { 68: strict?: boolean 69: defer_loading?: boolean 70: cache_control?: { 71: type: 'ephemeral' 72: scope?: 'global' | 'org' 73: ttl?: '5m' | '1h' 74: } 75: eager_input_streaming?: boolean 76: } 77: export type CacheScope = 'global' | 'org' 78: export type SystemPromptBlock = { 79: text: string 80: cacheScope: CacheScope | null 81: } 82: const SWARM_FIELDS_BY_TOOL: Record<string, string[]> = { 83: [EXIT_PLAN_MODE_V2_TOOL_NAME]: ['launchSwarm', 'teammateCount'], 84: [AGENT_TOOL_NAME]: ['name', 'team_name', 'mode'], 85: } 86: function filterSwarmFieldsFromSchema( 87: toolName: string, 88: schema: Anthropic.Tool.InputSchema, 89: ): Anthropic.Tool.InputSchema { 90: const fieldsToRemove = SWARM_FIELDS_BY_TOOL[toolName] 91: if (!fieldsToRemove || fieldsToRemove.length === 0) { 92: return schema 93: } 94: const filtered = { ...schema } 95: const props = filtered.properties 96: if (props && typeof props === 'object') { 97: const filteredProps = { ...(props as Record<string, unknown>) } 98: for (const field of fieldsToRemove) { 99: delete filteredProps[field] 100: } 101: filtered.properties = filteredProps 102: } 103: return filtered 104: } 105: export async function toolToAPISchema( 106: tool: Tool, 107: options: { 108: getToolPermissionContext: () => Promise<ToolPermissionContext> 109: tools: Tools 110: agents: AgentDefinition[] 111: allowedAgentTypes?: string[] 112: model?: string 113: deferLoading?: boolean 114: cacheControl?: { 115: type: 'ephemeral' 116: scope?: 'global' | 'org' 117: ttl?: '5m' | '1h' 118: } 119: }, 120: ): Promise<BetaToolUnion> { 121: const cacheKey = 122: 'inputJSONSchema' in tool && tool.inputJSONSchema 123: ? `${tool.name}:${jsonStringify(tool.inputJSONSchema)}` 124: : tool.name 125: const cache = getToolSchemaCache() 126: let base = cache.get(cacheKey) 127: if (!base) { 128: const strictToolsEnabled = 129: checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_tool_pear') 130: let input_schema = ( 131: 'inputJSONSchema' in tool && tool.inputJSONSchema 132: ? tool.inputJSONSchema 133: : zodToJsonSchema(tool.inputSchema) 134: ) as Anthropic.Tool.InputSchema 135: if (!isAgentSwarmsEnabled()) { 136: input_schema = filterSwarmFieldsFromSchema(tool.name, input_schema) 137: } 138: base = { 139: name: tool.name, 140: description: await tool.prompt({ 141: getToolPermissionContext: options.getToolPermissionContext, 142: tools: options.tools, 143: agents: options.agents, 144: allowedAgentTypes: options.allowedAgentTypes, 145: }), 146: input_schema, 147: } 148: if ( 149: strictToolsEnabled && 150: tool.strict === true && 151: options.model && 152: modelSupportsStructuredOutputs(options.model) 153: ) { 154: base.strict = true 155: } 156: if ( 157: getAPIProvider() === 'firstParty' && 158: isFirstPartyAnthropicBaseUrl() && 159: (getFeatureValue_CACHED_MAY_BE_STALE('tengu_fgts', false) || 160: isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_FINE_GRAINED_TOOL_STREAMING)) 161: ) { 162: base.eager_input_streaming = true 163: } 164: cache.set(cacheKey, base) 165: } 166: const schema: BetaToolWithExtras = { 167: name: base.name, 168: description: base.description, 169: input_schema: base.input_schema, 170: ...(base.strict && { strict: true }), 171: ...(base.eager_input_streaming && { eager_input_streaming: true }), 172: } 173: if (options.deferLoading) { 174: schema.defer_loading = true 175: } 176: if (options.cacheControl) { 177: schema.cache_control = options.cacheControl 178: } 179: if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS)) { 180: const allowed = new Set([ 181: 'name', 182: 'description', 183: 'input_schema', 184: 'cache_control', 185: ]) 186: const stripped = Object.keys(schema).filter(k => !allowed.has(k)) 187: if (stripped.length > 0) { 188: logStripOnce(stripped) 189: return { 190: name: schema.name, 191: description: schema.description, 192: input_schema: schema.input_schema, 193: ...(schema.cache_control && { cache_control: schema.cache_control }), 194: } 195: } 196: } 197: return schema as BetaTool 198: } 199: let loggedStrip = false 200: function logStripOnce(stripped: string[]): void { 201: if (loggedStrip) return 202: loggedStrip = true 203: logForDebugging( 204: `[betas] Stripped from tool schemas: [${stripped.join(', ')}] (CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1)`, 205: ) 206: } 207: export function logAPIPrefix(systemPrompt: SystemPrompt): void { 208: const [firstSyspromptBlock] = splitSysPromptPrefix(systemPrompt) 209: const firstSystemPrompt = firstSyspromptBlock?.text 210: logEvent('tengu_sysprompt_block', { 211: snippet: firstSystemPrompt?.slice( 212: 0, 213: 20, 214: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 215: length: firstSystemPrompt?.length ?? 0, 216: hash: (firstSystemPrompt 217: ? createHash('sha256').update(firstSystemPrompt).digest('hex') 218: : '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 219: }) 220: } 221: /** 222: * Split system prompt blocks by content type for API matching and cache control. 223: * See https://console.statsig.com/4aF3Ewatb6xPVpCwxb5nA3/dynamic_configs/claude_cli_system_prompt_prefixes 224: * 225: * Behavior depends on feature flags and options: 226: * 227: * 1. MCP tools present (skipGlobalCacheForSystemPrompt=true): 228: * Returns up to 3 blocks with org-level caching (no global cache on system prompt): 229: * - Attribution header (cacheScope=null) 230: * - System prompt prefix (cacheScope='org') 231: * - Everything else concatenated (cacheScope='org') 232: * 233: * 2. Global cache mode with boundary marker (1P only, boundary found): 234: * Returns up to 4 blocks: 235: * - Attribution header (cacheScope=null) 236: * - System prompt prefix (cacheScope=null) 237: * - Static content before boundary (cacheScope='global') 238: * - Dynamic content after boundary (cacheScope=null) 239: * 240: * 3. Default mode (3P providers, or boundary missing): 241: * Returns up to 3 blocks with org-level caching: 242: * - Attribution header (cacheScope=null) 243: * - System prompt prefix (cacheScope='org') 244: * - Everything else concatenated (cacheScope='org') 245: */ 246: export function splitSysPromptPrefix( 247: systemPrompt: SystemPrompt, 248: options?: { skipGlobalCacheForSystemPrompt?: boolean }, 249: ): SystemPromptBlock[] { 250: const useGlobalCacheFeature = shouldUseGlobalCacheScope() 251: if (useGlobalCacheFeature && options?.skipGlobalCacheForSystemPrompt) { 252: logEvent('tengu_sysprompt_using_tool_based_cache', { 253: promptBlockCount: systemPrompt.length, 254: }) 255: let attributionHeader: string | undefined 256: let systemPromptPrefix: string | undefined 257: const rest: string[] = [] 258: for (const prompt of systemPrompt) { 259: if (!prompt) continue 260: if (prompt === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue 261: if (prompt.startsWith('x-anthropic-billing-header')) { 262: attributionHeader = prompt 263: } else if (CLI_SYSPROMPT_PREFIXES.has(prompt)) { 264: systemPromptPrefix = prompt 265: } else { 266: rest.push(prompt) 267: } 268: } 269: const result: SystemPromptBlock[] = [] 270: if (attributionHeader) { 271: result.push({ text: attributionHeader, cacheScope: null }) 272: } 273: if (systemPromptPrefix) { 274: result.push({ text: systemPromptPrefix, cacheScope: 'org' }) 275: } 276: const restJoined = rest.join('\n\n') 277: if (restJoined) { 278: result.push({ text: restJoined, cacheScope: 'org' }) 279: } 280: return result 281: } 282: if (useGlobalCacheFeature) { 283: const boundaryIndex = systemPrompt.findIndex( 284: s => s === SYSTEM_PROMPT_DYNAMIC_BOUNDARY, 285: ) 286: if (boundaryIndex !== -1) { 287: let attributionHeader: string | undefined 288: let systemPromptPrefix: string | undefined 289: const staticBlocks: string[] = [] 290: const dynamicBlocks: string[] = [] 291: for (let i = 0; i < systemPrompt.length; i++) { 292: const block = systemPrompt[i] 293: if (!block || block === SYSTEM_PROMPT_DYNAMIC_BOUNDARY) continue 294: if (block.startsWith('x-anthropic-billing-header')) { 295: attributionHeader = block 296: } else if (CLI_SYSPROMPT_PREFIXES.has(block)) { 297: systemPromptPrefix = block 298: } else if (i < boundaryIndex) { 299: staticBlocks.push(block) 300: } else { 301: dynamicBlocks.push(block) 302: } 303: } 304: const result: SystemPromptBlock[] = [] 305: if (attributionHeader) 306: result.push({ text: attributionHeader, cacheScope: null }) 307: if (systemPromptPrefix) 308: result.push({ text: systemPromptPrefix, cacheScope: null }) 309: const staticJoined = staticBlocks.join('\n\n') 310: if (staticJoined) 311: result.push({ text: staticJoined, cacheScope: 'global' }) 312: const dynamicJoined = dynamicBlocks.join('\n\n') 313: if (dynamicJoined) result.push({ text: dynamicJoined, cacheScope: null }) 314: logEvent('tengu_sysprompt_boundary_found', { 315: blockCount: result.length, 316: staticBlockLength: staticJoined.length, 317: dynamicBlockLength: dynamicJoined.length, 318: }) 319: return result 320: } else { 321: logEvent('tengu_sysprompt_missing_boundary_marker', { 322: promptBlockCount: systemPrompt.length, 323: }) 324: } 325: } 326: let attributionHeader: string | undefined 327: let systemPromptPrefix: string | undefined 328: const rest: string[] = [] 329: for (const block of systemPrompt) { 330: if (!block) continue 331: if (block.startsWith('x-anthropic-billing-header')) { 332: attributionHeader = block 333: } else if (CLI_SYSPROMPT_PREFIXES.has(block)) { 334: systemPromptPrefix = block 335: } else { 336: rest.push(block) 337: } 338: } 339: const result: SystemPromptBlock[] = [] 340: if (attributionHeader) 341: result.push({ text: attributionHeader, cacheScope: null }) 342: if (systemPromptPrefix) 343: result.push({ text: systemPromptPrefix, cacheScope: 'org' }) 344: const restJoined = rest.join('\n\n') 345: if (restJoined) result.push({ text: restJoined, cacheScope: 'org' }) 346: return result 347: } 348: export function appendSystemContext( 349: systemPrompt: SystemPrompt, 350: context: { [k: string]: string }, 351: ): string[] { 352: return [ 353: ...systemPrompt, 354: Object.entries(context) 355: .map(([key, value]) => `${key}: ${value}`) 356: .join('\n'), 357: ].filter(Boolean) 358: } 359: export function prependUserContext( 360: messages: Message[], 361: context: { [k: string]: string }, 362: ): Message[] { 363: if (process.env.NODE_ENV === 'test') { 364: return messages 365: } 366: if (Object.entries(context).length === 0) { 367: return messages 368: } 369: return [ 370: createUserMessage({ 371: content: `<system-reminder>\nAs you answer the user's questions, you can use the following context:\n${Object.entries( 372: context, 373: ) 374: .map(([key, value]) => `# ${key}\n${value}`) 375: .join('\n')} 376: 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`, 377: isMeta: true, 378: }), 379: ...messages, 380: ] 381: } 382: export async function logContextMetrics( 383: mcpConfigs: Record<string, ScopedMcpServerConfig>, 384: toolPermissionContext: ToolPermissionContext, 385: ): Promise<void> { 386: if (isAnalyticsDisabled()) { 387: return 388: } 389: const [{ tools: mcpTools }, tools, userContext, systemContext] = 390: await Promise.all([ 391: prefetchAllMcpResources(mcpConfigs), 392: getTools(toolPermissionContext), 393: getUserContext(), 394: getSystemContext(), 395: ]) 396: const gitStatusSize = systemContext.gitStatus?.length ?? 0 397: const claudeMdSize = userContext.claudeMd?.length ?? 0 398: const totalContextSize = gitStatusSize + claudeMdSize 399: const currentDir = getCwd() 400: const ignorePatternsByRoot = getFileReadIgnorePatterns(toolPermissionContext) 401: const normalizedIgnorePatterns = normalizePatternsToPath( 402: ignorePatternsByRoot, 403: currentDir, 404: ) 405: const fileCount = await countFilesRoundedRg( 406: currentDir, 407: AbortSignal.timeout(1000), 408: normalizedIgnorePatterns, 409: ) 410: let mcpToolsCount = 0 411: let mcpServersCount = 0 412: let mcpToolsTokens = 0 413: let nonMcpToolsCount = 0 414: let nonMcpToolsTokens = 0 415: const nonMcpTools = tools.filter(tool => !tool.isMcp) 416: mcpToolsCount = mcpTools.length 417: nonMcpToolsCount = nonMcpTools.length 418: const serverNames = new Set<string>() 419: for (const tool of mcpTools) { 420: const parts = tool.name.split('__') 421: if (parts.length >= 3 && parts[1]) { 422: serverNames.add(parts[1]) 423: } 424: } 425: mcpServersCount = serverNames.size 426: for (const tool of mcpTools) { 427: const schema = 428: 'inputJSONSchema' in tool && tool.inputJSONSchema 429: ? tool.inputJSONSchema 430: : zodToJsonSchema(tool.inputSchema) 431: mcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema)) 432: } 433: for (const tool of nonMcpTools) { 434: const schema = 435: 'inputJSONSchema' in tool && tool.inputJSONSchema 436: ? tool.inputJSONSchema 437: : zodToJsonSchema(tool.inputSchema) 438: nonMcpToolsTokens += roughTokenCountEstimation(jsonStringify(schema)) 439: } 440: logEvent('tengu_context_size', { 441: git_status_size: gitStatusSize, 442: claude_md_size: claudeMdSize, 443: total_context_size: totalContextSize, 444: project_file_count_rounded: fileCount, 445: mcp_tools_count: mcpToolsCount, 446: mcp_servers_count: mcpServersCount, 447: mcp_tools_tokens: mcpToolsTokens, 448: non_mcp_tools_count: nonMcpToolsCount, 449: non_mcp_tools_tokens: nonMcpToolsTokens, 450: }) 451: } 452: export function normalizeToolInput<T extends Tool>( 453: tool: T, 454: input: z.infer<T['inputSchema']>, 455: agentId?: AgentId, 456: ): z.infer<T['inputSchema']> { 457: switch (tool.name) { 458: case EXIT_PLAN_MODE_V2_TOOL_NAME: { 459: const plan = getPlan(agentId) 460: const planFilePath = getPlanFilePath(agentId) 461: void persistFileSnapshotIfRemote() 462: return plan !== null ? { ...input, plan, planFilePath } : input 463: } 464: case BashTool.name: { 465: const parsed = BashTool.inputSchema.parse(input) 466: const { command, timeout, description } = parsed 467: const cwd = getCwd() 468: let normalizedCommand = command.replace(`cd ${cwd} && `, '') 469: if (getPlatform() === 'windows') { 470: normalizedCommand = normalizedCommand.replace( 471: `cd ${windowsPathToPosixPath(cwd)} && `, 472: '', 473: ) 474: } 475: // Replace \\; with \; (commonly needed for find -exec commands) 476: normalizedCommand = normalizedCommand.replace(/\\\\;/g, '\\;') 477: // Logging for commands that are only echoing a string. This is to help us understand how often Claude talks via bash 478: if (/^echo\s+["']?[^|&;><]*["']?$/i.test(normalizedCommand.trim())) { 479: logEvent('tengu_bash_tool_simple_echo', {}) 480: } 481: const run_in_background = 482: 'run_in_background' in parsed ? parsed.run_in_background : undefined 483: return { 484: command: normalizedCommand, 485: description, 486: ...(timeout !== undefined && { timeout }), 487: ...(description !== undefined && { description }), 488: ...(run_in_background !== undefined && { run_in_background }), 489: ...('dangerouslyDisableSandbox' in parsed && 490: parsed.dangerouslyDisableSandbox !== undefined && { 491: dangerouslyDisableSandbox: parsed.dangerouslyDisableSandbox, 492: }), 493: } as z.infer<T['inputSchema']> 494: } 495: case FileEditTool.name: { 496: const parsedInput = FileEditTool.inputSchema.parse(input) 497: const { file_path, edits } = normalizeFileEditInput({ 498: file_path: parsedInput.file_path, 499: edits: [ 500: { 501: old_string: parsedInput.old_string, 502: new_string: parsedInput.new_string, 503: replace_all: parsedInput.replace_all, 504: }, 505: ], 506: }) 507: return { 508: replace_all: edits[0]!.replace_all, 509: file_path, 510: old_string: edits[0]!.old_string, 511: new_string: edits[0]!.new_string, 512: } as z.infer<T['inputSchema']> 513: } 514: case FileWriteTool.name: { 515: const parsedInput = FileWriteTool.inputSchema.parse(input) 516: const isMarkdown = /\.(md|mdx)$/i.test(parsedInput.file_path) 517: return { 518: file_path: parsedInput.file_path, 519: content: isMarkdown 520: ? parsedInput.content 521: : stripTrailingWhitespace(parsedInput.content), 522: } as z.infer<T['inputSchema']> 523: } 524: case TASK_OUTPUT_TOOL_NAME: { 525: const legacyInput = input as Record<string, unknown> 526: const taskId = 527: legacyInput.task_id ?? legacyInput.agentId ?? legacyInput.bash_id 528: const timeout = 529: legacyInput.timeout ?? 530: (typeof legacyInput.wait_up_to === 'number' 531: ? legacyInput.wait_up_to * 1000 532: : undefined) 533: return { 534: task_id: taskId ?? '', 535: block: legacyInput.block ?? true, 536: timeout: timeout ?? 30000, 537: } as z.infer<T['inputSchema']> 538: } 539: default: 540: return input 541: } 542: } 543: export function normalizeToolInputForAPI<T extends Tool>( 544: tool: T, 545: input: z.infer<T['inputSchema']>, 546: ): z.infer<T['inputSchema']> { 547: switch (tool.name) { 548: case EXIT_PLAN_MODE_V2_TOOL_NAME: { 549: if ( 550: input && 551: typeof input === 'object' && 552: ('plan' in input || 'planFilePath' in input) 553: ) { 554: const { plan, planFilePath, ...rest } = input as Record<string, unknown> 555: return rest as z.infer<T['inputSchema']> 556: } 557: return input 558: } 559: case FileEditTool.name: { 560: if (input && typeof input === 'object' && 'edits' in input) { 561: const { old_string, new_string, replace_all, ...rest } = 562: input as Record<string, unknown> 563: return rest as z.infer<T['inputSchema']> 564: } 565: return input 566: } 567: default: 568: return input 569: } 570: }

File: src/utils/apiPreconnect.ts

typescript 1: import { getOauthConfig } from '../constants/oauth.js' 2: import { isEnvTruthy } from './envUtils.js' 3: let fired = false 4: export function preconnectAnthropicApi(): void { 5: if (fired) return 6: fired = true 7: if ( 8: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 9: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 10: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) 11: ) { 12: return 13: } 14: if ( 15: process.env.HTTPS_PROXY || 16: process.env.https_proxy || 17: process.env.HTTP_PROXY || 18: process.env.http_proxy || 19: process.env.ANTHROPIC_UNIX_SOCKET || 20: process.env.CLAUDE_CODE_CLIENT_CERT || 21: process.env.CLAUDE_CODE_CLIENT_KEY 22: ) { 23: return 24: } 25: const baseUrl = 26: process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL 27: void fetch(baseUrl, { 28: method: 'HEAD', 29: signal: AbortSignal.timeout(10_000), 30: }).catch(() => {}) 31: }

File: src/utils/appleTerminalBackup.ts

typescript 1: import { stat } from 'fs/promises' 2: import { homedir } from 'os' 3: import { join } from 'path' 4: import { getGlobalConfig, saveGlobalConfig } from './config.js' 5: import { execFileNoThrow } from './execFileNoThrow.js' 6: import { logError } from './log.js' 7: export function markTerminalSetupInProgress(backupPath: string): void { 8: saveGlobalConfig(current => ({ 9: ...current, 10: appleTerminalSetupInProgress: true, 11: appleTerminalBackupPath: backupPath, 12: })) 13: } 14: export function markTerminalSetupComplete(): void { 15: saveGlobalConfig(current => ({ 16: ...current, 17: appleTerminalSetupInProgress: false, 18: })) 19: } 20: function getTerminalRecoveryInfo(): { 21: inProgress: boolean 22: backupPath: string | null 23: } { 24: const config = getGlobalConfig() 25: return { 26: inProgress: config.appleTerminalSetupInProgress ?? false, 27: backupPath: config.appleTerminalBackupPath || null, 28: } 29: } 30: export function getTerminalPlistPath(): string { 31: return join(homedir(), 'Library', 'Preferences', 'com.apple.Terminal.plist') 32: } 33: export async function backupTerminalPreferences(): Promise<string | null> { 34: const terminalPlistPath = getTerminalPlistPath() 35: const backupPath = `${terminalPlistPath}.bak` 36: try { 37: const { code } = await execFileNoThrow('defaults', [ 38: 'export', 39: 'com.apple.Terminal', 40: terminalPlistPath, 41: ]) 42: if (code !== 0) { 43: return null 44: } 45: try { 46: await stat(terminalPlistPath) 47: } catch { 48: return null 49: } 50: await execFileNoThrow('defaults', [ 51: 'export', 52: 'com.apple.Terminal', 53: backupPath, 54: ]) 55: markTerminalSetupInProgress(backupPath) 56: return backupPath 57: } catch (error) { 58: logError(error) 59: return null 60: } 61: } 62: type RestoreResult = 63: | { 64: status: 'restored' | 'no_backup' 65: } 66: | { 67: status: 'failed' 68: backupPath: string 69: } 70: export async function checkAndRestoreTerminalBackup(): Promise<RestoreResult> { 71: const { inProgress, backupPath } = getTerminalRecoveryInfo() 72: if (!inProgress) { 73: return { status: 'no_backup' } 74: } 75: if (!backupPath) { 76: markTerminalSetupComplete() 77: return { status: 'no_backup' } 78: } 79: try { 80: await stat(backupPath) 81: } catch { 82: markTerminalSetupComplete() 83: return { status: 'no_backup' } 84: } 85: try { 86: const { code } = await execFileNoThrow('defaults', [ 87: 'import', 88: 'com.apple.Terminal', 89: backupPath, 90: ]) 91: if (code !== 0) { 92: return { status: 'failed', backupPath } 93: } 94: await execFileNoThrow('killall', ['cfprefsd']) 95: markTerminalSetupComplete() 96: return { status: 'restored' } 97: } catch (restoreError) { 98: logError( 99: new Error( 100: `Failed to restore Terminal.app settings with: ${restoreError}`, 101: ), 102: ) 103: markTerminalSetupComplete() 104: return { status: 'failed', backupPath } 105: } 106: }

File: src/utils/argumentSubstitution.ts

typescript 1: import { tryParseShellCommand } from './bash/shellQuote.js' 2: export function parseArguments(args: string): string[] { 3: if (!args || !args.trim()) { 4: return [] 5: } 6: const result = tryParseShellCommand(args, key => `$${key}`) 7: if (!result.success) { 8: return args.split(/\s+/).filter(Boolean) 9: } 10: return result.tokens.filter( 11: (token): token is string => typeof token === 'string', 12: ) 13: } 14: export function parseArgumentNames( 15: argumentNames: string | string[] | undefined, 16: ): string[] { 17: if (!argumentNames) { 18: return [] 19: } 20: const isValidName = (name: string): boolean => 21: typeof name === 'string' && name.trim() !== '' && !/^\d+$/.test(name) 22: if (Array.isArray(argumentNames)) { 23: return argumentNames.filter(isValidName) 24: } 25: if (typeof argumentNames === 'string') { 26: return argumentNames.split(/\s+/).filter(isValidName) 27: } 28: return [] 29: } 30: export function generateProgressiveArgumentHint( 31: argNames: string[], 32: typedArgs: string[], 33: ): string | undefined { 34: const remaining = argNames.slice(typedArgs.length) 35: if (remaining.length === 0) return undefined 36: return remaining.map(name => `[${name}]`).join(' ') 37: } 38: export function substituteArguments( 39: content: string, 40: args: string | undefined, 41: appendIfNoPlaceholder = true, 42: argumentNames: string[] = [], 43: ): string { 44: if (args === undefined || args === null) { 45: return content 46: } 47: const parsedArgs = parseArguments(args) 48: const originalContent = content 49: for (let i = 0; i < argumentNames.length; i++) { 50: const name = argumentNames[i] 51: if (!name) continue 52: content = content.replace( 53: new RegExp(`\\$${name}(?![\\[\\w])`, 'g'), 54: parsedArgs[i] ?? '', 55: ) 56: } 57: // Replace indexed arguments ($ARGUMENTS[0], $ARGUMENTS[1], etc.) 58: content = content.replace(/\$ARGUMENTS\[(\d+)\]/g, (_, indexStr: string) => { 59: const index = parseInt(indexStr, 10) 60: return parsedArgs[index] ?? '' 61: }) 62: // Replace shorthand indexed arguments ($0, $1, etc.) 63: content = content.replace(/\$(\d+)(?!\w)/g, (_, indexStr: string) => { 64: const index = parseInt(indexStr, 10) 65: return parsedArgs[index] ?? '' 66: }) 67: // Replace $ARGUMENTS with the full arguments string 68: content = content.replaceAll('$ARGUMENTS', args) 69: if (content === originalContent && appendIfNoPlaceholder && args) { 70: content = content + `\n\nARGUMENTS: ${args}` 71: } 72: return content 73: }

File: src/utils/array.ts

typescript 1: export function intersperse<A>(as: A[], separator: (index: number) => A): A[] { 2: return as.flatMap((a, i) => (i ? [separator(i), a] : [a])) 3: } 4: export function count<T>(arr: readonly T[], pred: (x: T) => unknown): number { 5: let n = 0 6: for (const x of arr) n += +!!pred(x) 7: return n 8: } 9: export function uniq<T>(xs: Iterable<T>): T[] { 10: return [...new Set(xs)] 11: }

File: src/utils/asciicast.ts

typescript 1: import { appendFile, rename } from 'fs/promises' 2: import { basename, dirname, join } from 'path' 3: import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' 4: import { createBufferedWriter } from './bufferedWriter.js' 5: import { registerCleanup } from './cleanupRegistry.js' 6: import { logForDebugging } from './debug.js' 7: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 8: import { getFsImplementation } from './fsOperations.js' 9: import { sanitizePath } from './path.js' 10: import { jsonStringify } from './slowOperations.js' 11: const recordingState: { filePath: string | null; timestamp: number } = { 12: filePath: null, 13: timestamp: 0, 14: } 15: export function getRecordFilePath(): string | null { 16: if (recordingState.filePath !== null) { 17: return recordingState.filePath 18: } 19: if (process.env.USER_TYPE !== 'ant') { 20: return null 21: } 22: if (!isEnvTruthy(process.env.CLAUDE_CODE_TERMINAL_RECORDING)) { 23: return null 24: } 25: const projectsDir = join(getClaudeConfigHomeDir(), 'projects') 26: const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) 27: recordingState.timestamp = Date.now() 28: recordingState.filePath = join( 29: projectDir, 30: `${getSessionId()}-${recordingState.timestamp}.cast`, 31: ) 32: return recordingState.filePath 33: } 34: export function _resetRecordingStateForTesting(): void { 35: recordingState.filePath = null 36: recordingState.timestamp = 0 37: } 38: export function getSessionRecordingPaths(): string[] { 39: const sessionId = getSessionId() 40: const projectsDir = join(getClaudeConfigHomeDir(), 'projects') 41: const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) 42: try { 43: const entries = getFsImplementation().readdirSync(projectDir) 44: const names = ( 45: typeof entries[0] === 'string' 46: ? entries 47: : (entries as { name: string }[]).map(e => e.name) 48: ) as string[] 49: const files = names 50: .filter(f => f.startsWith(sessionId) && f.endsWith('.cast')) 51: .sort() 52: return files.map(f => join(projectDir, f)) 53: } catch { 54: return [] 55: } 56: } 57: export async function renameRecordingForSession(): Promise<void> { 58: const oldPath = recordingState.filePath 59: if (!oldPath || recordingState.timestamp === 0) { 60: return 61: } 62: const projectsDir = join(getClaudeConfigHomeDir(), 'projects') 63: const projectDir = join(projectsDir, sanitizePath(getOriginalCwd())) 64: const newPath = join( 65: projectDir, 66: `${getSessionId()}-${recordingState.timestamp}.cast`, 67: ) 68: if (oldPath === newPath) { 69: return 70: } 71: await recorder?.flush() 72: const oldName = basename(oldPath) 73: const newName = basename(newPath) 74: try { 75: await rename(oldPath, newPath) 76: recordingState.filePath = newPath 77: logForDebugging(`[asciicast] Renamed recording: ${oldName} → ${newName}`) 78: } catch { 79: logForDebugging( 80: `[asciicast] Failed to rename recording from ${oldName} to ${newName}`, 81: ) 82: } 83: } 84: type AsciicastRecorder = { 85: flush(): Promise<void> 86: dispose(): Promise<void> 87: } 88: let recorder: AsciicastRecorder | null = null 89: function getTerminalSize(): { cols: number; rows: number } { 90: const cols = process.stdout.columns || 80 91: const rows = process.stdout.rows || 24 92: return { cols, rows } 93: } 94: export async function flushAsciicastRecorder(): Promise<void> { 95: await recorder?.flush() 96: } 97: export function installAsciicastRecorder(): void { 98: const filePath = getRecordFilePath() 99: if (!filePath) { 100: return 101: } 102: const { cols, rows } = getTerminalSize() 103: const startTime = performance.now() 104: const header = jsonStringify({ 105: version: 2, 106: width: cols, 107: height: rows, 108: timestamp: Math.floor(Date.now() / 1000), 109: env: { 110: SHELL: process.env.SHELL || '', 111: TERM: process.env.TERM || '', 112: }, 113: }) 114: try { 115: // eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts 116: getFsImplementation().mkdirSync(dirname(filePath)) 117: } catch { 118: // Directory may already exist 119: } 120: // eslint-disable-next-line custom-rules/no-sync-fs -- one-time init before Ink mounts 121: getFsImplementation().appendFileSync(filePath, header + '\n', { mode: 0o600 }) 122: let pendingWrite: Promise<void> = Promise.resolve() 123: const writer = createBufferedWriter({ 124: writeFn(content: string) { 125: const currentPath = recordingState.filePath 126: if (!currentPath) { 127: return 128: } 129: pendingWrite = pendingWrite 130: .then(() => appendFile(currentPath, content)) 131: .catch(() => { 132: }) 133: }, 134: flushIntervalMs: 500, 135: maxBufferSize: 50, 136: maxBufferBytes: 10 * 1024 * 1024, 137: }) 138: const originalWrite = process.stdout.write.bind( 139: process.stdout, 140: ) as typeof process.stdout.write 141: process.stdout.write = function ( 142: chunk: string | Uint8Array, 143: encodingOrCb?: BufferEncoding | ((err?: Error) => void), 144: cb?: (err?: Error) => void, 145: ): boolean { 146: const elapsed = (performance.now() - startTime) / 1000 147: const text = 148: typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8') 149: writer.write(jsonStringify([elapsed, 'o', text]) + '\n') 150: if (typeof encodingOrCb === 'function') { 151: return originalWrite(chunk, encodingOrCb) 152: } 153: return originalWrite(chunk, encodingOrCb, cb) 154: } as typeof process.stdout.write 155: function onResize(): void { 156: const elapsed = (performance.now() - startTime) / 1000 157: const { cols: newCols, rows: newRows } = getTerminalSize() 158: writer.write(jsonStringify([elapsed, 'r', `${newCols}x${newRows}`]) + '\n') 159: } 160: process.stdout.on('resize', onResize) 161: recorder = { 162: async flush(): Promise<void> { 163: writer.flush() 164: await pendingWrite 165: }, 166: async dispose(): Promise<void> { 167: writer.dispose() 168: await pendingWrite 169: process.stdout.removeListener('resize', onResize) 170: process.stdout.write = originalWrite 171: }, 172: } 173: registerCleanup(async () => { 174: await recorder?.dispose() 175: recorder = null 176: }) 177: logForDebugging(`[asciicast] Recording to ${filePath}`) 178: }

File: src/utils/attachments.ts

typescript 1: import { 2: logEvent, 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: } from 'src/services/analytics/index.js' 5: import { 6: toolMatchesName, 7: type Tools, 8: type ToolUseContext, 9: type ToolPermissionContext, 10: } from '../Tool.js' 11: import { 12: FileReadTool, 13: MaxFileReadTokenExceededError, 14: type Output as FileReadToolOutput, 15: readImageWithTokenBudget, 16: } from '../tools/FileReadTool/FileReadTool.js' 17: import { FileTooLargeError, readFileInRange } from './readFileInRange.js' 18: import { expandPath } from './path.js' 19: import { countCharInString } from './stringUtils.js' 20: import { count, uniq } from './array.js' 21: import { getFsImplementation } from './fsOperations.js' 22: import { readdir, stat } from 'fs/promises' 23: import type { IDESelection } from '../hooks/useIdeSelection.js' 24: import { TODO_WRITE_TOOL_NAME } from '../tools/TodoWriteTool/constants.js' 25: import { TASK_CREATE_TOOL_NAME } from '../tools/TaskCreateTool/constants.js' 26: import { TASK_UPDATE_TOOL_NAME } from '../tools/TaskUpdateTool/constants.js' 27: import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' 28: import { SKILL_TOOL_NAME } from '../tools/SkillTool/constants.js' 29: import type { TodoList } from './todo/types.js' 30: import { 31: type Task, 32: listTasks, 33: getTaskListId, 34: isTodoV2Enabled, 35: } from './tasks.js' 36: import { getPlanFilePath, getPlan } from './plans.js' 37: import { getConnectedIdeName } from './ide.js' 38: import { 39: filterInjectedMemoryFiles, 40: getManagedAndUserConditionalRules, 41: getMemoryFiles, 42: getMemoryFilesForNestedDirectory, 43: getConditionalRulesForCwdLevelDirectory, 44: type MemoryFileInfo, 45: } from './claudemd.js' 46: import { dirname, parse, relative, resolve } from 'path' 47: import { getCwd } from 'src/utils/cwd.js' 48: import { getViewedTeammateTask } from '../state/selectors.js' 49: import { logError } from './log.js' 50: import { logAntError } from './debug.js' 51: import { isENOENT, toError } from './errors.js' 52: import type { DiagnosticFile } from '../services/diagnosticTracking.js' 53: import { diagnosticTracker } from '../services/diagnosticTracking.js' 54: import type { 55: AttachmentMessage, 56: Message, 57: MessageOrigin, 58: } from 'src/types/message.js' 59: import { 60: type QueuedCommand, 61: getImagePasteIds, 62: isValidImagePaste, 63: } from 'src/types/textInputTypes.js' 64: import { randomUUID, type UUID } from 'crypto' 65: import { getSettings_DEPRECATED } from './settings/settings.js' 66: import { getSnippetForTwoFileDiff } from 'src/tools/FileEditTool/utils.js' 67: import type { 68: ContentBlockParam, 69: ImageBlockParam, 70: Base64ImageSource, 71: } from '@anthropic-ai/sdk/resources/messages.mjs' 72: import { maybeResizeAndDownsampleImageBlock } from './imageResizer.js' 73: import type { PastedContent } from './config.js' 74: import { getGlobalConfig } from './config.js' 75: import { 76: getDefaultSonnetModel, 77: getDefaultHaikuModel, 78: getDefaultOpusModel, 79: } from './model/model.js' 80: import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js' 81: import { getSkillToolCommands, getMcpSkillCommands } from '../commands.js' 82: import type { Command } from '../types/command.js' 83: import uniqBy from 'lodash-es/uniqBy.js' 84: import { getProjectRoot } from '../bootstrap/state.js' 85: import { formatCommandsWithinBudget } from '../tools/SkillTool/prompt.js' 86: import { getContextWindowForModel } from './context.js' 87: import type { DiscoverySignal } from '../services/skillSearch/signals.js' 88: const skillSearchModules = feature('EXPERIMENTAL_SKILL_SEARCH') 89: ? { 90: featureCheck: 91: require('../services/skillSearch/featureCheck.js') as typeof import('../services/skillSearch/featureCheck.js'), 92: prefetch: 93: require('../services/skillSearch/prefetch.js') as typeof import('../services/skillSearch/prefetch.js'), 94: } 95: : null 96: const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') 97: ? (require('./permissions/autoModeState.js') as typeof import('./permissions/autoModeState.js')) 98: : null 99: import { 100: MAX_LINES_TO_READ, 101: FILE_READ_TOOL_NAME, 102: } from 'src/tools/FileReadTool/prompt.js' 103: import { getDefaultFileReadingLimits } from 'src/tools/FileReadTool/limits.js' 104: import { cacheKeys, type FileStateCache } from './fileStateCache.js' 105: import { 106: createAbortController, 107: createChildAbortController, 108: } from './abortController.js' 109: import { isAbortError } from './errors.js' 110: import { 111: getFileModificationTimeAsync, 112: isFileWithinReadSizeLimit, 113: } from './file.js' 114: import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 115: import { filterAgentsByMcpRequirements } from '../tools/AgentTool/loadAgentsDir.js' 116: import { AGENT_TOOL_NAME } from '../tools/AgentTool/constants.js' 117: import { 118: formatAgentLine, 119: shouldInjectAgentListInMessages, 120: } from '../tools/AgentTool/prompt.js' 121: import { filterDeniedAgents } from './permissions/permissions.js' 122: import { getSubscriptionType } from './auth.js' 123: import { mcpInfoFromString } from '../services/mcp/mcpStringUtils.js' 124: import { 125: matchingRuleForInput, 126: pathInAllowedWorkingPath, 127: } from './permissions/filesystem.js' 128: import { 129: generateTaskAttachments, 130: applyTaskOffsetsAndEvictions, 131: } from './task/framework.js' 132: import { getTaskOutputPath } from './task/diskOutput.js' 133: import { drainPendingMessages } from '../tasks/LocalAgentTask/LocalAgentTask.js' 134: import type { TaskType, TaskStatus } from '../Task.js' 135: import { 136: getOriginalCwd, 137: getSessionId, 138: getSdkBetas, 139: getTotalCostUSD, 140: getTotalOutputTokens, 141: getCurrentTurnTokenBudget, 142: getTurnOutputTokens, 143: hasExitedPlanModeInSession, 144: setHasExitedPlanMode, 145: needsPlanModeExitAttachment, 146: setNeedsPlanModeExitAttachment, 147: needsAutoModeExitAttachment, 148: setNeedsAutoModeExitAttachment, 149: getLastEmittedDate, 150: setLastEmittedDate, 151: getKairosActive, 152: } from '../bootstrap/state.js' 153: import type { QuerySource } from '../constants/querySource.js' 154: import { 155: getDeferredToolsDelta, 156: isDeferredToolsDeltaEnabled, 157: isToolSearchEnabledOptimistic, 158: isToolSearchToolAvailable, 159: modelSupportsToolReference, 160: type DeferredToolsDeltaScanContext, 161: } from './toolSearch.js' 162: import { 163: getMcpInstructionsDelta, 164: isMcpInstructionsDeltaEnabled, 165: type ClientSideInstruction, 166: } from './mcpInstructionsDelta.js' 167: import { CLAUDE_IN_CHROME_MCP_SERVER_NAME } from './claudeInChrome/common.js' 168: import { CHROME_TOOL_SEARCH_INSTRUCTIONS } from './claudeInChrome/prompt.js' 169: import type { MCPServerConnection } from '../services/mcp/types.js' 170: import type { 171: HookEvent, 172: SyncHookJSONOutput, 173: } from 'src/entrypoints/agentSdkTypes.js' 174: import { 175: checkForAsyncHookResponses, 176: removeDeliveredAsyncHooks, 177: } from './hooks/AsyncHookRegistry.js' 178: import { 179: checkForLSPDiagnostics, 180: clearAllLSPDiagnostics, 181: } from '../services/lsp/LSPDiagnosticRegistry.js' 182: import { logForDebugging } from './debug.js' 183: import { 184: extractTextContent, 185: getUserMessageText, 186: isThinkingMessage, 187: } from './messages.js' 188: import { isHumanTurn } from './messagePredicates.js' 189: import { isEnvTruthy, getClaudeConfigHomeDir } from './envUtils.js' 190: import { feature } from 'bun:bundle' 191: const BRIEF_TOOL_NAME: string | null = 192: feature('KAIROS') || feature('KAIROS_BRIEF') 193: ? ( 194: require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') 195: ).BRIEF_TOOL_NAME 196: : null 197: const sessionTranscriptModule = feature('KAIROS') 198: ? (require('../services/sessionTranscript/sessionTranscript.js') as typeof import('../services/sessionTranscript/sessionTranscript.js')) 199: : null 200: import { hasUltrathinkKeyword, isUltrathinkEnabled } from './thinking.js' 201: import { 202: tokenCountFromLastAPIResponse, 203: tokenCountWithEstimation, 204: } from './tokens.js' 205: import { 206: getEffectiveContextWindowSize, 207: isAutoCompactEnabled, 208: } from '../services/compact/autoCompact.js' 209: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 210: import { 211: hasInstructionsLoadedHook, 212: executeInstructionsLoadedHooks, 213: type HookBlockingError, 214: type InstructionsMemoryType, 215: } from './hooks.js' 216: import { jsonStringify } from './slowOperations.js' 217: import { isPDFExtension } from './pdfUtils.js' 218: import { getLocalISODate } from '../constants/common.js' 219: import { getPDFPageCount } from './pdf.js' 220: import { PDF_AT_MENTION_INLINE_THRESHOLD } from '../constants/apiLimits.js' 221: import { isAgentSwarmsEnabled } from './agentSwarmsEnabled.js' 222: import { findRelevantMemories } from '../memdir/findRelevantMemories.js' 223: import { memoryAge, memoryFreshnessText } from '../memdir/memoryAge.js' 224: import { getAutoMemPath, isAutoMemoryEnabled } from '../memdir/paths.js' 225: import { getAgentMemoryDir } from '../tools/AgentTool/agentMemory.js' 226: import { 227: readUnreadMessages, 228: markMessagesAsReadByPredicate, 229: isShutdownApproved, 230: isStructuredProtocolMessage, 231: isIdleNotification, 232: } from './teammateMailbox.js' 233: import { 234: getAgentName, 235: getAgentId, 236: getTeamName, 237: isTeamLead, 238: } from './teammate.js' 239: import { isInProcessTeammate } from './teammateContext.js' 240: import { removeTeammateFromTeamFile } from './swarm/teamHelpers.js' 241: import { unassignTeammateTasks } from './tasks.js' 242: import { getCompanionIntroAttachment } from '../buddy/prompt.js' 243: export const TODO_REMINDER_CONFIG = { 244: TURNS_SINCE_WRITE: 10, 245: TURNS_BETWEEN_REMINDERS: 10, 246: } as const 247: export const PLAN_MODE_ATTACHMENT_CONFIG = { 248: TURNS_BETWEEN_ATTACHMENTS: 5, 249: FULL_REMINDER_EVERY_N_ATTACHMENTS: 5, 250: } as const 251: export const AUTO_MODE_ATTACHMENT_CONFIG = { 252: TURNS_BETWEEN_ATTACHMENTS: 5, 253: FULL_REMINDER_EVERY_N_ATTACHMENTS: 5, 254: } as const 255: const MAX_MEMORY_LINES = 200 256: const MAX_MEMORY_BYTES = 4096 257: export const RELEVANT_MEMORIES_CONFIG = { 258: MAX_SESSION_BYTES: 60 * 1024, 259: } as const 260: export const VERIFY_PLAN_REMINDER_CONFIG = { 261: TURNS_BETWEEN_REMINDERS: 10, 262: } as const 263: export type FileAttachment = { 264: type: 'file' 265: filename: string 266: content: FileReadToolOutput 267: truncated?: boolean 268: displayPath: string 269: } 270: export type CompactFileReferenceAttachment = { 271: type: 'compact_file_reference' 272: filename: string 273: displayPath: string 274: } 275: export type PDFReferenceAttachment = { 276: type: 'pdf_reference' 277: filename: string 278: pageCount: number 279: fileSize: number 280: displayPath: string 281: } 282: export type AlreadyReadFileAttachment = { 283: type: 'already_read_file' 284: filename: string 285: content: FileReadToolOutput 286: truncated?: boolean 287: displayPath: string 288: } 289: export type AgentMentionAttachment = { 290: type: 'agent_mention' 291: agentType: string 292: } 293: export type AsyncHookResponseAttachment = { 294: type: 'async_hook_response' 295: processId: string 296: hookName: string 297: hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 298: toolName?: string 299: response: SyncHookJSONOutput 300: stdout: string 301: stderr: string 302: exitCode?: number 303: } 304: export type HookAttachment = 305: | HookCancelledAttachment 306: | { 307: type: 'hook_blocking_error' 308: blockingError: HookBlockingError 309: hookName: string 310: toolUseID: string 311: hookEvent: HookEvent 312: } 313: | HookNonBlockingErrorAttachment 314: | HookErrorDuringExecutionAttachment 315: | { 316: type: 'hook_stopped_continuation' 317: message: string 318: hookName: string 319: toolUseID: string 320: hookEvent: HookEvent 321: } 322: | HookSuccessAttachment 323: | { 324: type: 'hook_additional_context' 325: content: string[] 326: hookName: string 327: toolUseID: string 328: hookEvent: HookEvent 329: } 330: | HookSystemMessageAttachment 331: | HookPermissionDecisionAttachment 332: export type HookPermissionDecisionAttachment = { 333: type: 'hook_permission_decision' 334: decision: 'allow' | 'deny' 335: toolUseID: string 336: hookEvent: HookEvent 337: } 338: export type HookSystemMessageAttachment = { 339: type: 'hook_system_message' 340: content: string 341: hookName: string 342: toolUseID: string 343: hookEvent: HookEvent 344: } 345: export type HookCancelledAttachment = { 346: type: 'hook_cancelled' 347: hookName: string 348: toolUseID: string 349: hookEvent: HookEvent 350: command?: string 351: durationMs?: number 352: } 353: export type HookErrorDuringExecutionAttachment = { 354: type: 'hook_error_during_execution' 355: content: string 356: hookName: string 357: toolUseID: string 358: hookEvent: HookEvent 359: command?: string 360: durationMs?: number 361: } 362: export type HookSuccessAttachment = { 363: type: 'hook_success' 364: content: string 365: hookName: string 366: toolUseID: string 367: hookEvent: HookEvent 368: stdout?: string 369: stderr?: string 370: exitCode?: number 371: command?: string 372: durationMs?: number 373: } 374: export type HookNonBlockingErrorAttachment = { 375: type: 'hook_non_blocking_error' 376: hookName: string 377: stderr: string 378: stdout: string 379: exitCode: number 380: toolUseID: string 381: hookEvent: HookEvent 382: command?: string 383: durationMs?: number 384: } 385: export type Attachment = 386: | FileAttachment 387: | CompactFileReferenceAttachment 388: | PDFReferenceAttachment 389: | AlreadyReadFileAttachment 390: | { 391: type: 'edited_text_file' 392: filename: string 393: snippet: string 394: } 395: | { 396: type: 'edited_image_file' 397: filename: string 398: content: FileReadToolOutput 399: } 400: | { 401: type: 'directory' 402: path: string 403: content: string 404: displayPath: string 405: } 406: | { 407: type: 'selected_lines_in_ide' 408: ideName: string 409: lineStart: number 410: lineEnd: number 411: filename: string 412: content: string 413: displayPath: string 414: } 415: | { 416: type: 'opened_file_in_ide' 417: filename: string 418: } 419: | { 420: type: 'todo_reminder' 421: content: TodoList 422: itemCount: number 423: } 424: | { 425: type: 'task_reminder' 426: content: Task[] 427: itemCount: number 428: } 429: | { 430: type: 'nested_memory' 431: path: string 432: content: MemoryFileInfo 433: displayPath: string 434: } 435: | { 436: type: 'relevant_memories' 437: memories: { 438: path: string 439: content: string 440: mtimeMs: number 441: header?: string 442: limit?: number 443: }[] 444: } 445: | { 446: type: 'dynamic_skill' 447: skillDir: string 448: skillNames: string[] 449: displayPath: string 450: } 451: | { 452: type: 'skill_listing' 453: content: string 454: skillCount: number 455: isInitial: boolean 456: } 457: | { 458: type: 'skill_discovery' 459: skills: { name: string; description: string; shortId?: string }[] 460: signal: DiscoverySignal 461: source: 'native' | 'aki' | 'both' 462: } 463: | { 464: type: 'queued_command' 465: prompt: string | Array<ContentBlockParam> 466: source_uuid?: UUID 467: imagePasteIds?: number[] 468: commandMode?: string 469: origin?: MessageOrigin 470: isMeta?: boolean 471: } 472: | { 473: type: 'output_style' 474: style: string 475: } 476: | { 477: type: 'diagnostics' 478: files: DiagnosticFile[] 479: isNew: boolean 480: } 481: | { 482: type: 'plan_mode' 483: reminderType: 'full' | 'sparse' 484: isSubAgent?: boolean 485: planFilePath: string 486: planExists: boolean 487: } 488: | { 489: type: 'plan_mode_reentry' 490: planFilePath: string 491: } 492: | { 493: type: 'plan_mode_exit' 494: planFilePath: string 495: planExists: boolean 496: } 497: | { 498: type: 'auto_mode' 499: reminderType: 'full' | 'sparse' 500: } 501: | { 502: type: 'auto_mode_exit' 503: } 504: | { 505: type: 'critical_system_reminder' 506: content: string 507: } 508: | { 509: type: 'plan_file_reference' 510: planFilePath: string 511: planContent: string 512: } 513: | { 514: type: 'mcp_resource' 515: server: string 516: uri: string 517: name: string 518: description?: string 519: content: ReadResourceResult 520: } 521: | { 522: type: 'command_permissions' 523: allowedTools: string[] 524: model?: string 525: } 526: | AgentMentionAttachment 527: | { 528: type: 'task_status' 529: taskId: string 530: taskType: TaskType 531: status: TaskStatus 532: description: string 533: deltaSummary: string | null 534: outputFilePath?: string 535: } 536: | AsyncHookResponseAttachment 537: | { 538: type: 'token_usage' 539: used: number 540: total: number 541: remaining: number 542: } 543: | { 544: type: 'budget_usd' 545: used: number 546: total: number 547: remaining: number 548: } 549: | { 550: type: 'output_token_usage' 551: turn: number 552: session: number 553: budget: number | null 554: } 555: | { 556: type: 'structured_output' 557: data: unknown 558: } 559: | TeammateMailboxAttachment 560: | TeamContextAttachment 561: | HookAttachment 562: | { 563: type: 'invoked_skills' 564: skills: Array<{ 565: name: string 566: path: string 567: content: string 568: }> 569: } 570: | { 571: type: 'verify_plan_reminder' 572: } 573: | { 574: type: 'max_turns_reached' 575: maxTurns: number 576: turnCount: number 577: } 578: | { 579: type: 'current_session_memory' 580: content: string 581: path: string 582: tokenCount: number 583: } 584: | { 585: type: 'teammate_shutdown_batch' 586: count: number 587: } 588: | { 589: type: 'compaction_reminder' 590: } 591: | { 592: type: 'context_efficiency' 593: } 594: | { 595: type: 'date_change' 596: newDate: string 597: } 598: | { 599: type: 'ultrathink_effort' 600: level: 'high' 601: } 602: | { 603: type: 'deferred_tools_delta' 604: addedNames: string[] 605: addedLines: string[] 606: removedNames: string[] 607: } 608: | { 609: type: 'agent_listing_delta' 610: addedTypes: string[] 611: addedLines: string[] 612: removedTypes: string[] 613: isInitial: boolean 614: showConcurrencyNote: boolean 615: } 616: | { 617: type: 'mcp_instructions_delta' 618: addedNames: string[] 619: addedBlocks: string[] 620: removedNames: string[] 621: } 622: | { 623: type: 'companion_intro' 624: name: string 625: species: string 626: } 627: | { 628: type: 'bagel_console' 629: errorCount: number 630: warningCount: number 631: sample: string 632: } 633: export type TeammateMailboxAttachment = { 634: type: 'teammate_mailbox' 635: messages: Array<{ 636: from: string 637: text: string 638: timestamp: string 639: color?: string 640: summary?: string 641: }> 642: } 643: export type TeamContextAttachment = { 644: type: 'team_context' 645: agentId: string 646: agentName: string 647: teamName: string 648: teamConfigPath: string 649: taskListPath: string 650: } 651: export async function getAttachments( 652: input: string | null, 653: toolUseContext: ToolUseContext, 654: ideSelection: IDESelection | null, 655: queuedCommands: QueuedCommand[], 656: messages?: Message[], 657: querySource?: QuerySource, 658: options?: { skipSkillDiscovery?: boolean }, 659: ): Promise<Attachment[]> { 660: if ( 661: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_ATTACHMENTS) || 662: isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) 663: ) { 664: return getQueuedCommandAttachments(queuedCommands) 665: } 666: const abortController = createAbortController() 667: const timeoutId = setTimeout(ac => ac.abort(), 1000, abortController) 668: const context = { ...toolUseContext, abortController } 669: const isMainThread = !toolUseContext.agentId 670: const userInputAttachments = input 671: ? [ 672: maybe('at_mentioned_files', () => 673: processAtMentionedFiles(input, context), 674: ), 675: maybe('mcp_resources', () => 676: processMcpResourceAttachments(input, context), 677: ), 678: maybe('agent_mentions', () => 679: Promise.resolve( 680: processAgentMentions( 681: input, 682: toolUseContext.options.agentDefinitions.activeAgents, 683: ), 684: ), 685: ), 686: ...(feature('EXPERIMENTAL_SKILL_SEARCH') && 687: skillSearchModules && 688: !options?.skipSkillDiscovery 689: ? [ 690: maybe('skill_discovery', () => 691: skillSearchModules.prefetch.getTurnZeroSkillDiscovery( 692: input, 693: messages ?? [], 694: context, 695: ), 696: ), 697: ] 698: : []), 699: ] 700: : [] 701: const userAttachmentResults = await Promise.all(userInputAttachments) 702: const allThreadAttachments = [ 703: maybe('queued_commands', () => getQueuedCommandAttachments(queuedCommands)), 704: maybe('date_change', () => 705: Promise.resolve(getDateChangeAttachments(messages)), 706: ), 707: maybe('ultrathink_effort', () => 708: Promise.resolve(getUltrathinkEffortAttachment(input)), 709: ), 710: maybe('deferred_tools_delta', () => 711: Promise.resolve( 712: getDeferredToolsDeltaAttachment( 713: toolUseContext.options.tools, 714: toolUseContext.options.mainLoopModel, 715: messages, 716: { 717: callSite: isMainThread 718: ? 'attachments_main' 719: : 'attachments_subagent', 720: querySource, 721: }, 722: ), 723: ), 724: ), 725: maybe('agent_listing_delta', () => 726: Promise.resolve(getAgentListingDeltaAttachment(toolUseContext, messages)), 727: ), 728: maybe('mcp_instructions_delta', () => 729: Promise.resolve( 730: getMcpInstructionsDeltaAttachment( 731: toolUseContext.options.mcpClients, 732: toolUseContext.options.tools, 733: toolUseContext.options.mainLoopModel, 734: messages, 735: ), 736: ), 737: ), 738: ...(feature('BUDDY') 739: ? [ 740: maybe('companion_intro', () => 741: Promise.resolve(getCompanionIntroAttachment(messages)), 742: ), 743: ] 744: : []), 745: maybe('changed_files', () => getChangedFiles(context)), 746: maybe('nested_memory', () => getNestedMemoryAttachments(context)), 747: maybe('dynamic_skill', () => getDynamicSkillAttachments(context)), 748: maybe('skill_listing', () => getSkillListingAttachments(context)), 749: maybe('plan_mode', () => getPlanModeAttachments(messages, toolUseContext)), 750: maybe('plan_mode_exit', () => getPlanModeExitAttachment(toolUseContext)), 751: ...(feature('TRANSCRIPT_CLASSIFIER') 752: ? [ 753: maybe('auto_mode', () => 754: getAutoModeAttachments(messages, toolUseContext), 755: ), 756: maybe('auto_mode_exit', () => 757: getAutoModeExitAttachment(toolUseContext), 758: ), 759: ] 760: : []), 761: maybe('todo_reminders', () => 762: isTodoV2Enabled() 763: ? getTaskReminderAttachments(messages, toolUseContext) 764: : getTodoReminderAttachments(messages, toolUseContext), 765: ), 766: ...(isAgentSwarmsEnabled() 767: ? [ 768: ...(querySource === 'session_memory' 769: ? [] 770: : [ 771: maybe('teammate_mailbox', async () => 772: getTeammateMailboxAttachments(toolUseContext), 773: ), 774: ]), 775: maybe('team_context', async () => 776: getTeamContextAttachment(messages ?? []), 777: ), 778: ] 779: : []), 780: maybe('agent_pending_messages', async () => 781: getAgentPendingMessageAttachments(toolUseContext), 782: ), 783: maybe('critical_system_reminder', () => 784: Promise.resolve(getCriticalSystemReminderAttachment(toolUseContext)), 785: ), 786: ...(feature('COMPACTION_REMINDERS') 787: ? [ 788: maybe('compaction_reminder', () => 789: Promise.resolve( 790: getCompactionReminderAttachment( 791: messages ?? [], 792: toolUseContext.options.mainLoopModel, 793: ), 794: ), 795: ), 796: ] 797: : []), 798: ...(feature('HISTORY_SNIP') 799: ? [ 800: maybe('context_efficiency', () => 801: Promise.resolve(getContextEfficiencyAttachment(messages ?? [])), 802: ), 803: ] 804: : []), 805: ] 806: const mainThreadAttachments = isMainThread 807: ? [ 808: maybe('ide_selection', async () => 809: getSelectedLinesFromIDE(ideSelection, toolUseContext), 810: ), 811: maybe('ide_opened_file', async () => 812: getOpenedFileFromIDE(ideSelection, toolUseContext), 813: ), 814: maybe('output_style', async () => 815: Promise.resolve(getOutputStyleAttachment()), 816: ), 817: maybe('diagnostics', async () => 818: getDiagnosticAttachments(toolUseContext), 819: ), 820: maybe('lsp_diagnostics', async () => 821: getLSPDiagnosticAttachments(toolUseContext), 822: ), 823: maybe('unified_tasks', async () => 824: getUnifiedTaskAttachments(toolUseContext), 825: ), 826: maybe('async_hook_responses', async () => 827: getAsyncHookResponseAttachments(), 828: ), 829: maybe('token_usage', async () => 830: Promise.resolve( 831: getTokenUsageAttachment( 832: messages ?? [], 833: toolUseContext.options.mainLoopModel, 834: ), 835: ), 836: ), 837: maybe('budget_usd', async () => 838: Promise.resolve( 839: getMaxBudgetUsdAttachment(toolUseContext.options.maxBudgetUsd), 840: ), 841: ), 842: maybe('output_token_usage', async () => 843: Promise.resolve(getOutputTokenUsageAttachment()), 844: ), 845: maybe('verify_plan_reminder', async () => 846: getVerifyPlanReminderAttachment(messages, toolUseContext), 847: ), 848: ] 849: : [] 850: const [threadAttachmentResults, mainThreadAttachmentResults] = 851: await Promise.all([ 852: Promise.all(allThreadAttachments), 853: Promise.all(mainThreadAttachments), 854: ]) 855: clearTimeout(timeoutId) 856: return [ 857: ...userAttachmentResults.flat(), 858: ...threadAttachmentResults.flat(), 859: ...mainThreadAttachmentResults.flat(), 860: ].filter(a => a !== undefined && a !== null) 861: } 862: async function maybe<A>(label: string, f: () => Promise<A[]>): Promise<A[]> { 863: const startTime = Date.now() 864: try { 865: const result = await f() 866: const duration = Date.now() - startTime 867: if (Math.random() < 0.05) { 868: const attachmentSizeBytes = result 869: .filter(a => a !== undefined && a !== null) 870: .reduce((total, attachment) => { 871: return total + jsonStringify(attachment).length 872: }, 0) 873: logEvent('tengu_attachment_compute_duration', { 874: label, 875: duration_ms: duration, 876: attachment_size_bytes: attachmentSizeBytes, 877: attachment_count: result.length, 878: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 879: } 880: return result 881: } catch (e) { 882: const duration = Date.now() - startTime 883: if (Math.random() < 0.05) { 884: logEvent('tengu_attachment_compute_duration', { 885: label, 886: duration_ms: duration, 887: error: true, 888: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 889: } 890: logError(e) 891: logAntError(`Attachment error in ${label}`, e) 892: return [] 893: } 894: } 895: const INLINE_NOTIFICATION_MODES = new Set(['prompt', 'task-notification']) 896: export async function getQueuedCommandAttachments( 897: queuedCommands: QueuedCommand[], 898: ): Promise<Attachment[]> { 899: if (!queuedCommands) { 900: return [] 901: } 902: const filtered = queuedCommands.filter(_ => 903: INLINE_NOTIFICATION_MODES.has(_.mode), 904: ) 905: return Promise.all( 906: filtered.map(async _ => { 907: const imageBlocks = await buildImageContentBlocks(_.pastedContents) 908: let prompt: string | Array<ContentBlockParam> = _.value 909: if (imageBlocks.length > 0) { 910: const textValue = 911: typeof _.value === 'string' 912: ? _.value 913: : extractTextContent(_.value, '\n') 914: prompt = [{ type: 'text' as const, text: textValue }, ...imageBlocks] 915: } 916: return { 917: type: 'queued_command' as const, 918: prompt, 919: source_uuid: _.uuid, 920: imagePasteIds: getImagePasteIds(_.pastedContents), 921: commandMode: _.mode, 922: origin: _.origin, 923: isMeta: _.isMeta, 924: } 925: }), 926: ) 927: } 928: export function getAgentPendingMessageAttachments( 929: toolUseContext: ToolUseContext, 930: ): Attachment[] { 931: const agentId = toolUseContext.agentId 932: if (!agentId) return [] 933: const drained = drainPendingMessages( 934: agentId, 935: toolUseContext.getAppState, 936: toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState, 937: ) 938: return drained.map(msg => ({ 939: type: 'queued_command' as const, 940: prompt: msg, 941: origin: { kind: 'coordinator' as const }, 942: isMeta: true, 943: })) 944: } 945: async function buildImageContentBlocks( 946: pastedContents: Record<number, PastedContent> | undefined, 947: ): Promise<ImageBlockParam[]> { 948: if (!pastedContents) { 949: return [] 950: } 951: const imageContents = Object.values(pastedContents).filter(isValidImagePaste) 952: if (imageContents.length === 0) { 953: return [] 954: } 955: const results = await Promise.all( 956: imageContents.map(async img => { 957: const imageBlock: ImageBlockParam = { 958: type: 'image', 959: source: { 960: type: 'base64', 961: media_type: (img.mediaType || 962: 'image/png') as Base64ImageSource['media_type'], 963: data: img.content, 964: }, 965: } 966: const resized = await maybeResizeAndDownsampleImageBlock(imageBlock) 967: return resized.block 968: }), 969: ) 970: return results 971: } 972: function getPlanModeAttachmentTurnCount(messages: Message[]): { 973: turnCount: number 974: foundPlanModeAttachment: boolean 975: } { 976: let turnsSinceLastAttachment = 0 977: let foundPlanModeAttachment = false 978: for (let i = messages.length - 1; i >= 0; i--) { 979: const message = messages[i] 980: if ( 981: message?.type === 'user' && 982: !message.isMeta && 983: !hasToolResultContent(message.message.content) 984: ) { 985: turnsSinceLastAttachment++ 986: } else if ( 987: message?.type === 'attachment' && 988: (message.attachment.type === 'plan_mode' || 989: message.attachment.type === 'plan_mode_reentry') 990: ) { 991: foundPlanModeAttachment = true 992: break 993: } 994: } 995: return { turnCount: turnsSinceLastAttachment, foundPlanModeAttachment } 996: } 997: function countPlanModeAttachmentsSinceLastExit(messages: Message[]): number { 998: let count = 0 999: for (let i = messages.length - 1; i >= 0; i--) { 1000: const message = messages[i] 1001: if (message?.type === 'attachment') { 1002: if (message.attachment.type === 'plan_mode_exit') { 1003: break 1004: } 1005: if (message.attachment.type === 'plan_mode') { 1006: count++ 1007: } 1008: } 1009: } 1010: return count 1011: } 1012: async function getPlanModeAttachments( 1013: messages: Message[] | undefined, 1014: toolUseContext: ToolUseContext, 1015: ): Promise<Attachment[]> { 1016: const appState = toolUseContext.getAppState() 1017: const permissionContext = appState.toolPermissionContext 1018: if (permissionContext.mode !== 'plan') { 1019: return [] 1020: } 1021: if (messages && messages.length > 0) { 1022: const { turnCount, foundPlanModeAttachment } = 1023: getPlanModeAttachmentTurnCount(messages) 1024: if ( 1025: foundPlanModeAttachment && 1026: turnCount < PLAN_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS 1027: ) { 1028: return [] 1029: } 1030: } 1031: const planFilePath = getPlanFilePath(toolUseContext.agentId) 1032: const existingPlan = getPlan(toolUseContext.agentId) 1033: const attachments: Attachment[] = [] 1034: if (hasExitedPlanModeInSession() && existingPlan !== null) { 1035: attachments.push({ type: 'plan_mode_reentry', planFilePath }) 1036: setHasExitedPlanMode(false) 1037: } 1038: const attachmentCount = 1039: countPlanModeAttachmentsSinceLastExit(messages ?? []) + 1 1040: const reminderType: 'full' | 'sparse' = 1041: attachmentCount % 1042: PLAN_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS === 1043: 1 1044: ? 'full' 1045: : 'sparse' 1046: attachments.push({ 1047: type: 'plan_mode', 1048: reminderType, 1049: isSubAgent: !!toolUseContext.agentId, 1050: planFilePath, 1051: planExists: existingPlan !== null, 1052: }) 1053: return attachments 1054: } 1055: async function getPlanModeExitAttachment( 1056: toolUseContext: ToolUseContext, 1057: ): Promise<Attachment[]> { 1058: if (!needsPlanModeExitAttachment()) { 1059: return [] 1060: } 1061: const appState = toolUseContext.getAppState() 1062: if (appState.toolPermissionContext.mode === 'plan') { 1063: setNeedsPlanModeExitAttachment(false) 1064: return [] 1065: } 1066: setNeedsPlanModeExitAttachment(false) 1067: const planFilePath = getPlanFilePath(toolUseContext.agentId) 1068: const planExists = getPlan(toolUseContext.agentId) !== null 1069: return [{ type: 'plan_mode_exit', planFilePath, planExists }] 1070: } 1071: function getAutoModeAttachmentTurnCount(messages: Message[]): { 1072: turnCount: number 1073: foundAutoModeAttachment: boolean 1074: } { 1075: let turnsSinceLastAttachment = 0 1076: let foundAutoModeAttachment = false 1077: for (let i = messages.length - 1; i >= 0; i--) { 1078: const message = messages[i] 1079: if ( 1080: message?.type === 'user' && 1081: !message.isMeta && 1082: !hasToolResultContent(message.message.content) 1083: ) { 1084: turnsSinceLastAttachment++ 1085: } else if ( 1086: message?.type === 'attachment' && 1087: message.attachment.type === 'auto_mode' 1088: ) { 1089: foundAutoModeAttachment = true 1090: break 1091: } else if ( 1092: message?.type === 'attachment' && 1093: message.attachment.type === 'auto_mode_exit' 1094: ) { 1095: break 1096: } 1097: } 1098: return { turnCount: turnsSinceLastAttachment, foundAutoModeAttachment } 1099: } 1100: function countAutoModeAttachmentsSinceLastExit(messages: Message[]): number { 1101: let count = 0 1102: for (let i = messages.length - 1; i >= 0; i--) { 1103: const message = messages[i] 1104: if (message?.type === 'attachment') { 1105: if (message.attachment.type === 'auto_mode_exit') { 1106: break 1107: } 1108: if (message.attachment.type === 'auto_mode') { 1109: count++ 1110: } 1111: } 1112: } 1113: return count 1114: } 1115: async function getAutoModeAttachments( 1116: messages: Message[] | undefined, 1117: toolUseContext: ToolUseContext, 1118: ): Promise<Attachment[]> { 1119: const appState = toolUseContext.getAppState() 1120: const permissionContext = appState.toolPermissionContext 1121: const inAuto = permissionContext.mode === 'auto' 1122: const inPlanWithAuto = 1123: permissionContext.mode === 'plan' && 1124: (autoModeStateModule?.isAutoModeActive() ?? false) 1125: if (!inAuto && !inPlanWithAuto) { 1126: return [] 1127: } 1128: if (messages && messages.length > 0) { 1129: const { turnCount, foundAutoModeAttachment } = 1130: getAutoModeAttachmentTurnCount(messages) 1131: if ( 1132: foundAutoModeAttachment && 1133: turnCount < AUTO_MODE_ATTACHMENT_CONFIG.TURNS_BETWEEN_ATTACHMENTS 1134: ) { 1135: return [] 1136: } 1137: } 1138: const attachmentCount = 1139: countAutoModeAttachmentsSinceLastExit(messages ?? []) + 1 1140: const reminderType: 'full' | 'sparse' = 1141: attachmentCount % 1142: AUTO_MODE_ATTACHMENT_CONFIG.FULL_REMINDER_EVERY_N_ATTACHMENTS === 1143: 1 1144: ? 'full' 1145: : 'sparse' 1146: return [{ type: 'auto_mode', reminderType }] 1147: } 1148: async function getAutoModeExitAttachment( 1149: toolUseContext: ToolUseContext, 1150: ): Promise<Attachment[]> { 1151: if (!needsAutoModeExitAttachment()) { 1152: return [] 1153: } 1154: const appState = toolUseContext.getAppState() 1155: if ( 1156: appState.toolPermissionContext.mode === 'auto' || 1157: (autoModeStateModule?.isAutoModeActive() ?? false) 1158: ) { 1159: setNeedsAutoModeExitAttachment(false) 1160: return [] 1161: } 1162: setNeedsAutoModeExitAttachment(false) 1163: return [{ type: 'auto_mode_exit' }] 1164: } 1165: export function getDateChangeAttachments( 1166: messages: Message[] | undefined, 1167: ): Attachment[] { 1168: const currentDate = getLocalISODate() 1169: const lastDate = getLastEmittedDate() 1170: if (lastDate === null) { 1171: setLastEmittedDate(currentDate) 1172: return [] 1173: } 1174: if (currentDate === lastDate) { 1175: return [] 1176: } 1177: setLastEmittedDate(currentDate) 1178: if (feature('KAIROS')) { 1179: if (getKairosActive() && messages !== undefined) { 1180: sessionTranscriptModule?.flushOnDateChange(messages, currentDate) 1181: } 1182: } 1183: return [{ type: 'date_change', newDate: currentDate }] 1184: } 1185: function getUltrathinkEffortAttachment(input: string | null): Attachment[] { 1186: if (!isUltrathinkEnabled() || !input || !hasUltrathinkKeyword(input)) { 1187: return [] 1188: } 1189: logEvent('tengu_ultrathink', {}) 1190: return [{ type: 'ultrathink_effort', level: 'high' }] 1191: } 1192: export function getDeferredToolsDeltaAttachment( 1193: tools: Tools, 1194: model: string, 1195: messages: Message[] | undefined, 1196: scanContext?: DeferredToolsDeltaScanContext, 1197: ): Attachment[] { 1198: if (!isDeferredToolsDeltaEnabled()) return [] 1199: if (!isToolSearchEnabledOptimistic()) return [] 1200: if (!modelSupportsToolReference(model)) return [] 1201: if (!isToolSearchToolAvailable(tools)) return [] 1202: const delta = getDeferredToolsDelta(tools, messages ?? [], scanContext) 1203: if (!delta) return [] 1204: return [{ type: 'deferred_tools_delta', ...delta }] 1205: } 1206: export function getAgentListingDeltaAttachment( 1207: toolUseContext: ToolUseContext, 1208: messages: Message[] | undefined, 1209: ): Attachment[] { 1210: if (!shouldInjectAgentListInMessages()) return [] 1211: if ( 1212: !toolUseContext.options.tools.some(t => toolMatchesName(t, AGENT_TOOL_NAME)) 1213: ) { 1214: return [] 1215: } 1216: const { activeAgents, allowedAgentTypes } = 1217: toolUseContext.options.agentDefinitions 1218: const mcpServers = new Set<string>() 1219: for (const tool of toolUseContext.options.tools) { 1220: const info = mcpInfoFromString(tool.name) 1221: if (info) mcpServers.add(info.serverName) 1222: } 1223: const permissionContext = toolUseContext.getAppState().toolPermissionContext 1224: let filtered = filterDeniedAgents( 1225: filterAgentsByMcpRequirements(activeAgents, [...mcpServers]), 1226: permissionContext, 1227: AGENT_TOOL_NAME, 1228: ) 1229: if (allowedAgentTypes) { 1230: filtered = filtered.filter(a => allowedAgentTypes.includes(a.agentType)) 1231: } 1232: const announced = new Set<string>() 1233: for (const msg of messages ?? []) { 1234: if (msg.type !== 'attachment') continue 1235: if (msg.attachment.type !== 'agent_listing_delta') continue 1236: for (const t of msg.attachment.addedTypes) announced.add(t) 1237: for (const t of msg.attachment.removedTypes) announced.delete(t) 1238: } 1239: const currentTypes = new Set(filtered.map(a => a.agentType)) 1240: const added = filtered.filter(a => !announced.has(a.agentType)) 1241: const removed: string[] = [] 1242: for (const t of announced) { 1243: if (!currentTypes.has(t)) removed.push(t) 1244: } 1245: if (added.length === 0 && removed.length === 0) return [] 1246: added.sort((a, b) => a.agentType.localeCompare(b.agentType)) 1247: removed.sort() 1248: return [ 1249: { 1250: type: 'agent_listing_delta', 1251: addedTypes: added.map(a => a.agentType), 1252: addedLines: added.map(formatAgentLine), 1253: removedTypes: removed, 1254: isInitial: announced.size === 0, 1255: showConcurrencyNote: getSubscriptionType() !== 'pro', 1256: }, 1257: ] 1258: } 1259: export function getMcpInstructionsDeltaAttachment( 1260: mcpClients: MCPServerConnection[], 1261: tools: Tools, 1262: model: string, 1263: messages: Message[] | undefined, 1264: ): Attachment[] { 1265: if (!isMcpInstructionsDeltaEnabled()) return [] 1266: const clientSide: ClientSideInstruction[] = [] 1267: if ( 1268: isToolSearchEnabledOptimistic() && 1269: modelSupportsToolReference(model) && 1270: isToolSearchToolAvailable(tools) 1271: ) { 1272: clientSide.push({ 1273: serverName: CLAUDE_IN_CHROME_MCP_SERVER_NAME, 1274: block: CHROME_TOOL_SEARCH_INSTRUCTIONS, 1275: }) 1276: } 1277: const delta = getMcpInstructionsDelta(mcpClients, messages ?? [], clientSide) 1278: if (!delta) return [] 1279: return [{ type: 'mcp_instructions_delta', ...delta }] 1280: } 1281: function getCriticalSystemReminderAttachment( 1282: toolUseContext: ToolUseContext, 1283: ): Attachment[] { 1284: const reminder = toolUseContext.criticalSystemReminder_EXPERIMENTAL 1285: if (!reminder) { 1286: return [] 1287: } 1288: return [{ type: 'critical_system_reminder', content: reminder }] 1289: } 1290: function getOutputStyleAttachment(): Attachment[] { 1291: const settings = getSettings_DEPRECATED() 1292: const outputStyle = settings?.outputStyle || 'default' 1293: if (outputStyle === 'default') { 1294: return [] 1295: } 1296: return [ 1297: { 1298: type: 'output_style', 1299: style: outputStyle, 1300: }, 1301: ] 1302: } 1303: async function getSelectedLinesFromIDE( 1304: ideSelection: IDESelection | null, 1305: toolUseContext: ToolUseContext, 1306: ): Promise<Attachment[]> { 1307: const ideName = getConnectedIdeName(toolUseContext.options.mcpClients) 1308: if ( 1309: !ideName || 1310: ideSelection?.lineStart === undefined || 1311: !ideSelection.text || 1312: !ideSelection.filePath 1313: ) { 1314: return [] 1315: } 1316: const appState = toolUseContext.getAppState() 1317: if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) { 1318: return [] 1319: } 1320: return [ 1321: { 1322: type: 'selected_lines_in_ide', 1323: ideName, 1324: lineStart: ideSelection.lineStart, 1325: lineEnd: ideSelection.lineStart + ideSelection.lineCount - 1, 1326: filename: ideSelection.filePath, 1327: content: ideSelection.text, 1328: displayPath: relative(getCwd(), ideSelection.filePath), 1329: }, 1330: ] 1331: } 1332: export function getDirectoriesToProcess( 1333: targetPath: string, 1334: originalCwd: string, 1335: ): { nestedDirs: string[]; cwdLevelDirs: string[] } { 1336: const targetDir = dirname(resolve(targetPath)) 1337: const nestedDirs: string[] = [] 1338: let currentDir = targetDir 1339: while (currentDir !== originalCwd && currentDir !== parse(currentDir).root) { 1340: if (currentDir.startsWith(originalCwd)) { 1341: nestedDirs.push(currentDir) 1342: } 1343: currentDir = dirname(currentDir) 1344: } 1345: nestedDirs.reverse() 1346: const cwdLevelDirs: string[] = [] 1347: currentDir = originalCwd 1348: while (currentDir !== parse(currentDir).root) { 1349: cwdLevelDirs.push(currentDir) 1350: currentDir = dirname(currentDir) 1351: } 1352: cwdLevelDirs.reverse() 1353: return { nestedDirs, cwdLevelDirs } 1354: } 1355: function isInstructionsMemoryType( 1356: type: MemoryFileInfo['type'], 1357: ): type is InstructionsMemoryType { 1358: return ( 1359: type === 'User' || 1360: type === 'Project' || 1361: type === 'Local' || 1362: type === 'Managed' 1363: ) 1364: } 1365: export function memoryFilesToAttachments( 1366: memoryFiles: MemoryFileInfo[], 1367: toolUseContext: ToolUseContext, 1368: triggerFilePath?: string, 1369: ): Attachment[] { 1370: const attachments: Attachment[] = [] 1371: const shouldFireHook = hasInstructionsLoadedHook() 1372: for (const memoryFile of memoryFiles) { 1373: if (toolUseContext.loadedNestedMemoryPaths?.has(memoryFile.path)) { 1374: continue 1375: } 1376: if (!toolUseContext.readFileState.has(memoryFile.path)) { 1377: attachments.push({ 1378: type: 'nested_memory', 1379: path: memoryFile.path, 1380: content: memoryFile, 1381: displayPath: relative(getCwd(), memoryFile.path), 1382: }) 1383: toolUseContext.loadedNestedMemoryPaths?.add(memoryFile.path) 1384: toolUseContext.readFileState.set(memoryFile.path, { 1385: content: memoryFile.contentDiffersFromDisk 1386: ? (memoryFile.rawContent ?? memoryFile.content) 1387: : memoryFile.content, 1388: timestamp: Date.now(), 1389: offset: undefined, 1390: limit: undefined, 1391: isPartialView: memoryFile.contentDiffersFromDisk, 1392: }) 1393: if (shouldFireHook && isInstructionsMemoryType(memoryFile.type)) { 1394: const loadReason = memoryFile.globs 1395: ? 'path_glob_match' 1396: : memoryFile.parent 1397: ? 'include' 1398: : 'nested_traversal' 1399: void executeInstructionsLoadedHooks( 1400: memoryFile.path, 1401: memoryFile.type, 1402: loadReason, 1403: { 1404: globs: memoryFile.globs, 1405: triggerFilePath, 1406: parentFilePath: memoryFile.parent, 1407: }, 1408: ) 1409: } 1410: } 1411: } 1412: return attachments 1413: } 1414: async function getNestedMemoryAttachmentsForFile( 1415: filePath: string, 1416: toolUseContext: ToolUseContext, 1417: appState: { toolPermissionContext: ToolPermissionContext }, 1418: ): Promise<Attachment[]> { 1419: const attachments: Attachment[] = [] 1420: try { 1421: if (!pathInAllowedWorkingPath(filePath, appState.toolPermissionContext)) { 1422: return attachments 1423: } 1424: const processedPaths = new Set<string>() 1425: const originalCwd = getOriginalCwd() 1426: const managedUserRules = await getManagedAndUserConditionalRules( 1427: filePath, 1428: processedPaths, 1429: ) 1430: attachments.push( 1431: ...memoryFilesToAttachments(managedUserRules, toolUseContext, filePath), 1432: ) 1433: const { nestedDirs, cwdLevelDirs } = getDirectoriesToProcess( 1434: filePath, 1435: originalCwd, 1436: ) 1437: const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE( 1438: 'tengu_paper_halyard', 1439: false, 1440: ) 1441: for (const dir of nestedDirs) { 1442: const memoryFiles = ( 1443: await getMemoryFilesForNestedDirectory(dir, filePath, processedPaths) 1444: ).filter( 1445: f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'), 1446: ) 1447: attachments.push( 1448: ...memoryFilesToAttachments(memoryFiles, toolUseContext, filePath), 1449: ) 1450: } 1451: for (const dir of cwdLevelDirs) { 1452: const conditionalRules = ( 1453: await getConditionalRulesForCwdLevelDirectory( 1454: dir, 1455: filePath, 1456: processedPaths, 1457: ) 1458: ).filter( 1459: f => !skipProjectLevel || (f.type !== 'Project' && f.type !== 'Local'), 1460: ) 1461: attachments.push( 1462: ...memoryFilesToAttachments(conditionalRules, toolUseContext, filePath), 1463: ) 1464: } 1465: } catch (error) { 1466: logError(error) 1467: } 1468: return attachments 1469: } 1470: async function getOpenedFileFromIDE( 1471: ideSelection: IDESelection | null, 1472: toolUseContext: ToolUseContext, 1473: ): Promise<Attachment[]> { 1474: if (!ideSelection?.filePath || ideSelection.text) { 1475: return [] 1476: } 1477: const appState = toolUseContext.getAppState() 1478: if (isFileReadDenied(ideSelection.filePath, appState.toolPermissionContext)) { 1479: return [] 1480: } 1481: const nestedMemoryAttachments = await getNestedMemoryAttachmentsForFile( 1482: ideSelection.filePath, 1483: toolUseContext, 1484: appState, 1485: ) 1486: return [ 1487: ...nestedMemoryAttachments, 1488: { 1489: type: 'opened_file_in_ide', 1490: filename: ideSelection.filePath, 1491: }, 1492: ] 1493: } 1494: async function processAtMentionedFiles( 1495: input: string, 1496: toolUseContext: ToolUseContext, 1497: ): Promise<Attachment[]> { 1498: const files = extractAtMentionedFiles(input) 1499: if (files.length === 0) return [] 1500: const appState = toolUseContext.getAppState() 1501: const results = await Promise.all( 1502: files.map(async file => { 1503: try { 1504: const { filename, lineStart, lineEnd } = parseAtMentionedFileLines(file) 1505: const absoluteFilename = expandPath(filename) 1506: if ( 1507: isFileReadDenied(absoluteFilename, appState.toolPermissionContext) 1508: ) { 1509: return null 1510: } 1511: try { 1512: const stats = await stat(absoluteFilename) 1513: if (stats.isDirectory()) { 1514: try { 1515: const entries = await readdir(absoluteFilename, { 1516: withFileTypes: true, 1517: }) 1518: const MAX_DIR_ENTRIES = 1000 1519: const truncated = entries.length > MAX_DIR_ENTRIES 1520: const names = entries.slice(0, MAX_DIR_ENTRIES).map(e => e.name) 1521: if (truncated) { 1522: names.push( 1523: `\u2026 and ${entries.length - MAX_DIR_ENTRIES} more entries`, 1524: ) 1525: } 1526: const stdout = names.join('\n') 1527: logEvent('tengu_at_mention_extracting_directory_success', {}) 1528: return { 1529: type: 'directory' as const, 1530: path: absoluteFilename, 1531: content: stdout, 1532: displayPath: relative(getCwd(), absoluteFilename), 1533: } 1534: } catch { 1535: return null 1536: } 1537: } 1538: } catch { 1539: } 1540: return await generateFileAttachment( 1541: absoluteFilename, 1542: toolUseContext, 1543: 'tengu_at_mention_extracting_filename_success', 1544: 'tengu_at_mention_extracting_filename_error', 1545: 'at-mention', 1546: { 1547: offset: lineStart, 1548: limit: lineEnd && lineStart ? lineEnd - lineStart + 1 : undefined, 1549: }, 1550: ) 1551: } catch { 1552: logEvent('tengu_at_mention_extracting_filename_error', {}) 1553: } 1554: }), 1555: ) 1556: return results.filter(Boolean) as Attachment[] 1557: } 1558: function processAgentMentions( 1559: input: string, 1560: agents: AgentDefinition[], 1561: ): Attachment[] { 1562: const agentMentions = extractAgentMentions(input) 1563: if (agentMentions.length === 0) return [] 1564: const results = agentMentions.map(mention => { 1565: const agentType = mention.replace('agent-', '') 1566: const agentDef = agents.find(def => def.agentType === agentType) 1567: if (!agentDef) { 1568: logEvent('tengu_at_mention_agent_not_found', {}) 1569: return null 1570: } 1571: logEvent('tengu_at_mention_agent_success', {}) 1572: return { 1573: type: 'agent_mention' as const, 1574: agentType: agentDef.agentType, 1575: } 1576: }) 1577: return results.filter( 1578: (result): result is NonNullable<typeof result> => result !== null, 1579: ) 1580: } 1581: async function processMcpResourceAttachments( 1582: input: string, 1583: toolUseContext: ToolUseContext, 1584: ): Promise<Attachment[]> { 1585: const resourceMentions = extractMcpResourceMentions(input) 1586: if (resourceMentions.length === 0) return [] 1587: const mcpClients = toolUseContext.options.mcpClients || [] 1588: const results = await Promise.all( 1589: resourceMentions.map(async mention => { 1590: try { 1591: const [serverName, ...uriParts] = mention.split(':') 1592: const uri = uriParts.join(':') 1593: if (!serverName || !uri) { 1594: logEvent('tengu_at_mention_mcp_resource_error', {}) 1595: return null 1596: } 1597: const client = mcpClients.find(c => c.name === serverName) 1598: if (!client || client.type !== 'connected') { 1599: logEvent('tengu_at_mention_mcp_resource_error', {}) 1600: return null 1601: } 1602: const serverResources = 1603: toolUseContext.options.mcpResources?.[serverName] || [] 1604: const resourceInfo = serverResources.find(r => r.uri === uri) 1605: if (!resourceInfo) { 1606: logEvent('tengu_at_mention_mcp_resource_error', {}) 1607: return null 1608: } 1609: try { 1610: const result = await client.client.readResource({ 1611: uri, 1612: }) 1613: logEvent('tengu_at_mention_mcp_resource_success', {}) 1614: return { 1615: type: 'mcp_resource' as const, 1616: server: serverName, 1617: uri, 1618: name: resourceInfo.name || uri, 1619: description: resourceInfo.description, 1620: content: result, 1621: } 1622: } catch (error) { 1623: logEvent('tengu_at_mention_mcp_resource_error', {}) 1624: logError(error) 1625: return null 1626: } 1627: } catch { 1628: logEvent('tengu_at_mention_mcp_resource_error', {}) 1629: return null 1630: } 1631: }), 1632: ) 1633: return results.filter( 1634: (result): result is NonNullable<typeof result> => result !== null, 1635: ) as Attachment[] 1636: } 1637: export async function getChangedFiles( 1638: toolUseContext: ToolUseContext, 1639: ): Promise<Attachment[]> { 1640: const filePaths = cacheKeys(toolUseContext.readFileState) 1641: if (filePaths.length === 0) return [] 1642: const appState = toolUseContext.getAppState() 1643: const results = await Promise.all( 1644: filePaths.map(async filePath => { 1645: const fileState = toolUseContext.readFileState.get(filePath) 1646: if (!fileState) return null 1647: if (fileState.offset !== undefined || fileState.limit !== undefined) { 1648: return null 1649: } 1650: const normalizedPath = expandPath(filePath) 1651: if (isFileReadDenied(normalizedPath, appState.toolPermissionContext)) { 1652: return null 1653: } 1654: try { 1655: const mtime = await getFileModificationTimeAsync(normalizedPath) 1656: if (mtime <= fileState.timestamp) { 1657: return null 1658: } 1659: const fileInput = { file_path: normalizedPath } 1660: const isValid = await FileReadTool.validateInput( 1661: fileInput, 1662: toolUseContext, 1663: ) 1664: if (!isValid.result) { 1665: return null 1666: } 1667: const result = await FileReadTool.call(fileInput, toolUseContext) 1668: if (result.data.type === 'text') { 1669: const snippet = getSnippetForTwoFileDiff( 1670: fileState.content, 1671: result.data.file.content, 1672: ) 1673: if (snippet === '') { 1674: return null 1675: } 1676: return { 1677: type: 'edited_text_file' as const, 1678: filename: normalizedPath, 1679: snippet, 1680: } 1681: } 1682: if (result.data.type === 'image') { 1683: try { 1684: const data = await readImageWithTokenBudget(normalizedPath) 1685: return { 1686: type: 'edited_image_file' as const, 1687: filename: normalizedPath, 1688: content: data, 1689: } 1690: } catch (compressionError) { 1691: logError(compressionError) 1692: logEvent('tengu_watched_file_compression_failed', { 1693: file: normalizedPath, 1694: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 1695: return null 1696: } 1697: } 1698: return null 1699: } catch (err) { 1700: if (isENOENT(err)) { 1701: toolUseContext.readFileState.delete(filePath) 1702: } 1703: return null 1704: } 1705: }), 1706: ) 1707: return results.filter(result => result != null) as Attachment[] 1708: } 1709: async function getNestedMemoryAttachments( 1710: toolUseContext: ToolUseContext, 1711: ): Promise<Attachment[]> { 1712: if ( 1713: !toolUseContext.nestedMemoryAttachmentTriggers || 1714: toolUseContext.nestedMemoryAttachmentTriggers.size === 0 1715: ) { 1716: return [] 1717: } 1718: const appState = toolUseContext.getAppState() 1719: const attachments: Attachment[] = [] 1720: for (const filePath of toolUseContext.nestedMemoryAttachmentTriggers) { 1721: const nestedAttachments = await getNestedMemoryAttachmentsForFile( 1722: filePath, 1723: toolUseContext, 1724: appState, 1725: ) 1726: attachments.push(...nestedAttachments) 1727: } 1728: toolUseContext.nestedMemoryAttachmentTriggers.clear() 1729: return attachments 1730: } 1731: async function getRelevantMemoryAttachments( 1732: input: string, 1733: agents: AgentDefinition[], 1734: readFileState: FileStateCache, 1735: recentTools: readonly string[], 1736: signal: AbortSignal, 1737: alreadySurfaced: ReadonlySet<string>, 1738: ): Promise<Attachment[]> { 1739: const memoryDirs = extractAgentMentions(input).flatMap(mention => { 1740: const agentType = mention.replace('agent-', '') 1741: const agentDef = agents.find(def => def.agentType === agentType) 1742: return agentDef?.memory 1743: ? [getAgentMemoryDir(agentType, agentDef.memory)] 1744: : [] 1745: }) 1746: const dirs = memoryDirs.length > 0 ? memoryDirs : [getAutoMemPath()] 1747: const allResults = await Promise.all( 1748: dirs.map(dir => 1749: findRelevantMemories( 1750: input, 1751: dir, 1752: signal, 1753: recentTools, 1754: alreadySurfaced, 1755: ).catch(() => []), 1756: ), 1757: ) 1758: // alreadySurfaced is filtered inside the selector so Sonnet spends its 1759: // 5-slot budget on fresh candidates; readFileState catches files the 1760: // model read via FileReadTool. The redundant alreadySurfaced check here 1761: // is a belt-and-suspenders guard (multi-dir results may re-introduce a 1762: // path the selector filtered in a different dir). 1763: const selected = allResults 1764: .flat() 1765: .filter(m => !readFileState.has(m.path) && !alreadySurfaced.has(m.path)) 1766: .slice(0, 5) 1767: const memories = await readMemoriesForSurfacing(selected, signal) 1768: if (memories.length === 0) { 1769: return [] 1770: } 1771: return [{ type: 'relevant_memories' as const, memories }] 1772: } 1773: export function collectSurfacedMemories(messages: ReadonlyArray<Message>): { 1774: paths: Set<string> 1775: totalBytes: number 1776: } { 1777: const paths = new Set<string>() 1778: let totalBytes = 0 1779: for (const m of messages) { 1780: if (m.type === 'attachment' && m.attachment.type === 'relevant_memories') { 1781: for (const mem of m.attachment.memories) { 1782: paths.add(mem.path) 1783: totalBytes += mem.content.length 1784: } 1785: } 1786: } 1787: return { paths, totalBytes } 1788: } 1789: export async function readMemoriesForSurfacing( 1790: selected: ReadonlyArray<{ path: string; mtimeMs: number }>, 1791: signal?: AbortSignal, 1792: ): Promise< 1793: Array<{ 1794: path: string 1795: content: string 1796: mtimeMs: number 1797: header: string 1798: limit?: number 1799: }> 1800: > { 1801: const results = await Promise.all( 1802: selected.map(async ({ path: filePath, mtimeMs }) => { 1803: try { 1804: const result = await readFileInRange( 1805: filePath, 1806: 0, 1807: MAX_MEMORY_LINES, 1808: MAX_MEMORY_BYTES, 1809: signal, 1810: { truncateOnByteLimit: true }, 1811: ) 1812: const truncated = 1813: result.totalLines > MAX_MEMORY_LINES || result.truncatedByBytes 1814: const content = truncated 1815: ? result.content + 1816: `\n\n> This memory file was truncated (${result.truncatedByBytes ? `${MAX_MEMORY_BYTES} byte limit` : `first ${MAX_MEMORY_LINES} lines`}). Use the ${FILE_READ_TOOL_NAME} tool to view the complete file at: ${filePath}` 1817: : result.content 1818: return { 1819: path: filePath, 1820: content, 1821: mtimeMs, 1822: header: memoryHeader(filePath, mtimeMs), 1823: limit: truncated ? result.lineCount : undefined, 1824: } 1825: } catch { 1826: return null 1827: } 1828: }), 1829: ) 1830: return results.filter(r => r !== null) 1831: } 1832: /** 1833: * Header string for a relevant-memory block. Exported so messages.ts 1834: * can fall back for resumed sessions where the stored header is missing. 1835: */ 1836: export function memoryHeader(path: string, mtimeMs: number): string { 1837: const staleness = memoryFreshnessText(mtimeMs) 1838: return staleness 1839: ? `${staleness}\n\nMemory: ${path}:` 1840: : `Memory (saved ${memoryAge(mtimeMs)}): ${path}:` 1841: } 1842: /** 1843: * A memory relevance-selector prefetch handle. The promise is started once 1844: * per user turn and runs while the main model streams and tools execute. 1845: * At the collect point (post-tools), the caller reads settledAt to 1846: * consume-if-ready or skip-and-retry-next-iteration — the prefetch never 1847: * blocks the turn. 1848: * 1849: * Disposable: query.ts binds with `using`, so [Symbol.dispose] fires on all 1850: * generator exit paths (return, throw, .return() closure) — aborting the 1851: * in-flight request and emitting terminal telemetry without instrumenting 1852: * each of the ~13 return sites inside the while loop. 1853: */ 1854: export type MemoryPrefetch = { 1855: promise: Promise<Attachment[]> 1856: settledAt: number | null 1857: consumedOnIteration: number 1858: [Symbol.dispose](): void 1859: } 1860: export function startRelevantMemoryPrefetch( 1861: messages: ReadonlyArray<Message>, 1862: toolUseContext: ToolUseContext, 1863: ): MemoryPrefetch | undefined { 1864: if ( 1865: !isAutoMemoryEnabled() || 1866: !getFeatureValue_CACHED_MAY_BE_STALE('tengu_moth_copse', false) 1867: ) { 1868: return undefined 1869: } 1870: const lastUserMessage = messages.findLast(m => m.type === 'user' && !m.isMeta) 1871: if (!lastUserMessage) { 1872: return undefined 1873: } 1874: const input = getUserMessageText(lastUserMessage) 1875: if (!input || !/\s/.test(input.trim())) { 1876: return undefined 1877: } 1878: const surfaced = collectSurfacedMemories(messages) 1879: if (surfaced.totalBytes >= RELEVANT_MEMORIES_CONFIG.MAX_SESSION_BYTES) { 1880: return undefined 1881: } 1882: const controller = createChildAbortController(toolUseContext.abortController) 1883: const firedAt = Date.now() 1884: const promise = getRelevantMemoryAttachments( 1885: input, 1886: toolUseContext.options.agentDefinitions.activeAgents, 1887: toolUseContext.readFileState, 1888: collectRecentSuccessfulTools(messages, lastUserMessage), 1889: controller.signal, 1890: surfaced.paths, 1891: ).catch(e => { 1892: if (!isAbortError(e)) { 1893: logError(e) 1894: } 1895: return [] 1896: }) 1897: const handle: MemoryPrefetch = { 1898: promise, 1899: settledAt: null, 1900: consumedOnIteration: -1, 1901: [Symbol.dispose]() { 1902: controller.abort() 1903: logEvent('tengu_memdir_prefetch_collected', { 1904: hidden_by_first_iteration: 1905: handle.settledAt !== null && handle.consumedOnIteration === 0, 1906: consumed_on_iteration: handle.consumedOnIteration, 1907: latency_ms: (handle.settledAt ?? Date.now()) - firedAt, 1908: }) 1909: }, 1910: } 1911: void promise.finally(() => { 1912: handle.settledAt = Date.now() 1913: }) 1914: return handle 1915: } 1916: type ToolResultBlock = { 1917: type: 'tool_result' 1918: tool_use_id: string 1919: is_error?: boolean 1920: } 1921: function isToolResultBlock(b: unknown): b is ToolResultBlock { 1922: return ( 1923: typeof b === 'object' && 1924: b !== null && 1925: (b as ToolResultBlock).type === 'tool_result' && 1926: typeof (b as ToolResultBlock).tool_use_id === 'string' 1927: ) 1928: } 1929: function hasToolResultContent(content: unknown): boolean { 1930: return Array.isArray(content) && content.some(isToolResultBlock) 1931: } 1932: export function collectRecentSuccessfulTools( 1933: messages: ReadonlyArray<Message>, 1934: lastUserMessage: Message, 1935: ): readonly string[] { 1936: const useIdToName = new Map<string, string>() 1937: const resultByUseId = new Map<string, boolean>() 1938: for (let i = messages.length - 1; i >= 0; i--) { 1939: const m = messages[i] 1940: if (!m) continue 1941: if (isHumanTurn(m) && m !== lastUserMessage) break 1942: if (m.type === 'assistant' && typeof m.message.content !== 'string') { 1943: for (const block of m.message.content) { 1944: if (block.type === 'tool_use') useIdToName.set(block.id, block.name) 1945: } 1946: } else if ( 1947: m.type === 'user' && 1948: 'message' in m && 1949: Array.isArray(m.message.content) 1950: ) { 1951: for (const block of m.message.content) { 1952: if (isToolResultBlock(block)) { 1953: resultByUseId.set(block.tool_use_id, block.is_error === true) 1954: } 1955: } 1956: } 1957: } 1958: const failed = new Set<string>() 1959: const succeeded = new Set<string>() 1960: for (const [id, name] of useIdToName) { 1961: const errored = resultByUseId.get(id) 1962: if (errored === undefined) continue 1963: if (errored) { 1964: failed.add(name) 1965: } else { 1966: succeeded.add(name) 1967: } 1968: } 1969: return [...succeeded].filter(t => !failed.has(t)) 1970: } 1971: export function filterDuplicateMemoryAttachments( 1972: attachments: Attachment[], 1973: readFileState: FileStateCache, 1974: ): Attachment[] { 1975: return attachments 1976: .map(attachment => { 1977: if (attachment.type !== 'relevant_memories') return attachment 1978: const filtered = attachment.memories.filter( 1979: m => !readFileState.has(m.path), 1980: ) 1981: for (const m of filtered) { 1982: readFileState.set(m.path, { 1983: content: m.content, 1984: timestamp: m.mtimeMs, 1985: offset: undefined, 1986: limit: m.limit, 1987: }) 1988: } 1989: return filtered.length > 0 ? { ...attachment, memories: filtered } : null 1990: }) 1991: .filter((a): a is Attachment => a !== null) 1992: } 1993: async function getDynamicSkillAttachments( 1994: toolUseContext: ToolUseContext, 1995: ): Promise<Attachment[]> { 1996: const attachments: Attachment[] = [] 1997: if ( 1998: toolUseContext.dynamicSkillDirTriggers && 1999: toolUseContext.dynamicSkillDirTriggers.size > 0 2000: ) { 2001: const perDirResults = await Promise.all( 2002: Array.from(toolUseContext.dynamicSkillDirTriggers).map(async skillDir => { 2003: try { 2004: const entries = await readdir(skillDir, { withFileTypes: true }) 2005: const candidates = entries 2006: .filter(e => e.isDirectory() || e.isSymbolicLink()) 2007: .map(e => e.name) 2008: const checked = await Promise.all( 2009: candidates.map(async name => { 2010: try { 2011: await stat(resolve(skillDir, name, 'SKILL.md')) 2012: return name 2013: } catch { 2014: return null 2015: } 2016: }), 2017: ) 2018: return { 2019: skillDir, 2020: skillNames: checked.filter((n): n is string => n !== null), 2021: } 2022: } catch { 2023: return { skillDir, skillNames: [] } 2024: } 2025: }), 2026: ) 2027: for (const { skillDir, skillNames } of perDirResults) { 2028: if (skillNames.length > 0) { 2029: attachments.push({ 2030: type: 'dynamic_skill', 2031: skillDir, 2032: skillNames, 2033: displayPath: relative(getCwd(), skillDir), 2034: }) 2035: } 2036: } 2037: toolUseContext.dynamicSkillDirTriggers.clear() 2038: } 2039: return attachments 2040: } 2041: const sentSkillNames = new Map<string, Set<string>>() 2042: export function resetSentSkillNames(): void { 2043: sentSkillNames.clear() 2044: suppressNext = false 2045: } 2046: export function suppressNextSkillListing(): void { 2047: suppressNext = true 2048: } 2049: let suppressNext = false 2050: const FILTERED_LISTING_MAX = 30 2051: export function filterToBundledAndMcp(commands: Command[]): Command[] { 2052: const filtered = commands.filter( 2053: cmd => cmd.loadedFrom === 'bundled' || cmd.loadedFrom === 'mcp', 2054: ) 2055: if (filtered.length > FILTERED_LISTING_MAX) { 2056: return filtered.filter(cmd => cmd.loadedFrom === 'bundled') 2057: } 2058: return filtered 2059: } 2060: async function getSkillListingAttachments( 2061: toolUseContext: ToolUseContext, 2062: ): Promise<Attachment[]> { 2063: if (process.env.NODE_ENV === 'test') { 2064: return [] 2065: } 2066: if ( 2067: !toolUseContext.options.tools.some(t => toolMatchesName(t, SKILL_TOOL_NAME)) 2068: ) { 2069: return [] 2070: } 2071: const cwd = getProjectRoot() 2072: const localCommands = await getSkillToolCommands(cwd) 2073: const mcpSkills = getMcpSkillCommands( 2074: toolUseContext.getAppState().mcp.commands, 2075: ) 2076: let allCommands = 2077: mcpSkills.length > 0 2078: ? uniqBy([...localCommands, ...mcpSkills], 'name') 2079: : localCommands 2080: if ( 2081: feature('EXPERIMENTAL_SKILL_SEARCH') && 2082: skillSearchModules?.featureCheck.isSkillSearchEnabled() 2083: ) { 2084: allCommands = filterToBundledAndMcp(allCommands) 2085: } 2086: const agentKey = toolUseContext.agentId ?? '' 2087: let sent = sentSkillNames.get(agentKey) 2088: if (!sent) { 2089: sent = new Set() 2090: sentSkillNames.set(agentKey, sent) 2091: } 2092: // Resume path: prior process already injected a listing; it's in the 2093: if (suppressNext) { 2094: suppressNext = false 2095: for (const cmd of allCommands) { 2096: sent.add(cmd.name) 2097: } 2098: return [] 2099: } 2100: const newSkills = allCommands.filter(cmd => !sent.has(cmd.name)) 2101: if (newSkills.length === 0) { 2102: return [] 2103: } 2104: const isInitial = sent.size === 0 2105: for (const cmd of newSkills) { 2106: sent.add(cmd.name) 2107: } 2108: logForDebugging( 2109: `Sending ${newSkills.length} skills via attachment (${isInitial ? 'initial' : 'dynamic'}, ${sent.size} total sent)`, 2110: ) 2111: const contextWindowTokens = getContextWindowForModel( 2112: toolUseContext.options.mainLoopModel, 2113: getSdkBetas(), 2114: ) 2115: const content = formatCommandsWithinBudget(newSkills, contextWindowTokens) 2116: return [ 2117: { 2118: type: 'skill_listing', 2119: content, 2120: skillCount: newSkills.length, 2121: isInitial, 2122: }, 2123: ] 2124: } 2125: export function extractAtMentionedFiles(content: string): string[] { 2126: const quotedAtMentionRegex = /(^|\s)@"([^"]+)"/g 2127: const regularAtMentionRegex = /(^|\s)@([^\s]+)\b/g 2128: const quotedMatches: string[] = [] 2129: const regularMatches: string[] = [] 2130: // Extract quoted mentions first (skip agent mentions like @"code-reviewer (agent)") 2131: let match 2132: while ((match = quotedAtMentionRegex.exec(content)) !== null) { 2133: if (match[2] && !match[2].endsWith(' (agent)')) { 2134: quotedMatches.push(match[2]) // The content inside quotes 2135: } 2136: } 2137: // Extract regular mentions 2138: const regularMatchArray = content.match(regularAtMentionRegex) || [] 2139: regularMatchArray.forEach(match => { 2140: const filename = match.slice(match.indexOf('@') + 1) 2141: // Don't include if it starts with a quote (already handled as quoted) 2142: if (!filename.startsWith('"')) { 2143: regularMatches.push(filename) 2144: } 2145: }) 2146: // Combine and deduplicate 2147: return uniq([...quotedMatches, ...regularMatches]) 2148: } 2149: export function extractMcpResourceMentions(content: string): string[] { 2150: // Extract MCP resources mentioned with @ symbol in format @server:uri 2151: // Example: "@server1:resource/path" would extract "server1:resource/path" 2152: const atMentionRegex = /(^|\s)@([^\s]+:[^\s]+)\b/g 2153: const matches = content.match(atMentionRegex) || [] 2154: // Remove the prefix (everything before @) from each match 2155: return uniq(matches.map(match => match.slice(match.indexOf('@') + 1))) 2156: } 2157: export function extractAgentMentions(content: string): string[] { 2158: // Extract agent mentions in two formats: 2159: // 1. @agent-<agent-type> (legacy/manual typing) 2160: // Example: "@agent-code-elegance-refiner" → "agent-code-elegance-refiner" 2161: // 2. @"<agent-type> (agent)" (from autocomplete selection) 2162: // Example: '@"code-reviewer (agent)"' → "code-reviewer" 2163: // Supports colons, dots, and at-signs for plugin-scoped agents like "@agent-asana:project-status-updater" 2164: const results: string[] = [] 2165: // Match quoted format: @"<type> (agent)" 2166: const quotedAgentRegex = /(^|\s)@"([\w:.@-]+) \(agent\)"/g 2167: let match 2168: while ((match = quotedAgentRegex.exec(content)) !== null) { 2169: if (match[2]) { 2170: results.push(match[2]) 2171: } 2172: } 2173: // Match unquoted format: @agent-<type> 2174: const unquotedAgentRegex = /(^|\s)@(agent-[\w:.@-]+)/g 2175: const unquotedMatches = content.match(unquotedAgentRegex) || [] 2176: for (const m of unquotedMatches) { 2177: results.push(m.slice(m.indexOf('@') + 1)) 2178: } 2179: return uniq(results) 2180: } 2181: interface AtMentionedFileLines { 2182: filename: string 2183: lineStart?: number 2184: lineEnd?: number 2185: } 2186: export function parseAtMentionedFileLines( 2187: mention: string, 2188: ): AtMentionedFileLines { 2189: // Parse mentions like "file.txt#L10-20", "file.txt#heading", or just "file.txt" 2190: // Supports line ranges (#L10, #L10-20) and strips non-line-range fragments (#heading) 2191: const match = mention.match(/^([^#]+)(?:#L(\d+)(?:-(\d+))?)?(?:#[^#]*)?$/) 2192: if (!match) { 2193: return { filename: mention } 2194: } 2195: const [, filename, lineStartStr, lineEndStr] = match 2196: const lineStart = lineStartStr ? parseInt(lineStartStr, 10) : undefined 2197: const lineEnd = lineEndStr ? parseInt(lineEndStr, 10) : lineStart 2198: return { filename: filename ?? mention, lineStart, lineEnd } 2199: } 2200: async function getDiagnosticAttachments( 2201: toolUseContext: ToolUseContext, 2202: ): Promise<Attachment[]> { 2203: // Diagnostics are only useful if the agent has the Bash tool to act on them 2204: if ( 2205: !toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME)) 2206: ) { 2207: return [] 2208: } 2209: // Get new diagnostics from the tracker (IDE diagnostics via MCP) 2210: const newDiagnostics = await diagnosticTracker.getNewDiagnostics() 2211: if (newDiagnostics.length === 0) { 2212: return [] 2213: } 2214: return [ 2215: { 2216: type: 'diagnostics', 2217: files: newDiagnostics, 2218: isNew: true, 2219: }, 2220: ] 2221: } 2222: async function getLSPDiagnosticAttachments( 2223: toolUseContext: ToolUseContext, 2224: ): Promise<Attachment[]> { 2225: if ( 2226: !toolUseContext.options.tools.some(t => toolMatchesName(t, BASH_TOOL_NAME)) 2227: ) { 2228: return [] 2229: } 2230: logForDebugging('LSP Diagnostics: getLSPDiagnosticAttachments called') 2231: try { 2232: const diagnosticSets = checkForLSPDiagnostics() 2233: if (diagnosticSets.length === 0) { 2234: return [] 2235: } 2236: logForDebugging( 2237: `LSP Diagnostics: Found ${diagnosticSets.length} pending diagnostic set(s)`, 2238: ) 2239: const attachments: Attachment[] = diagnosticSets.map(({ files }) => ({ 2240: type: 'diagnostics' as const, 2241: files, 2242: isNew: true, 2243: })) 2244: if (diagnosticSets.length > 0) { 2245: clearAllLSPDiagnostics() 2246: logForDebugging( 2247: `LSP Diagnostics: Cleared ${diagnosticSets.length} delivered diagnostic(s) from registry`, 2248: ) 2249: } 2250: logForDebugging( 2251: `LSP Diagnostics: Returning ${attachments.length} diagnostic attachment(s)`, 2252: ) 2253: return attachments 2254: } catch (error) { 2255: const err = toError(error) 2256: logError( 2257: new Error(`Failed to get LSP diagnostic attachments: ${err.message}`), 2258: ) 2259: return [] 2260: } 2261: } 2262: export async function* getAttachmentMessages( 2263: input: string | null, 2264: toolUseContext: ToolUseContext, 2265: ideSelection: IDESelection | null, 2266: queuedCommands: QueuedCommand[], 2267: messages?: Message[], 2268: querySource?: QuerySource, 2269: options?: { skipSkillDiscovery?: boolean }, 2270: ): AsyncGenerator<AttachmentMessage, void> { 2271: const attachments = await getAttachments( 2272: input, 2273: toolUseContext, 2274: ideSelection, 2275: queuedCommands, 2276: messages, 2277: querySource, 2278: options, 2279: ) 2280: if (attachments.length === 0) { 2281: return 2282: } 2283: logEvent('tengu_attachments', { 2284: attachment_types: attachments.map( 2285: _ => _.type, 2286: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2287: }) 2288: for (const attachment of attachments) { 2289: yield createAttachmentMessage(attachment) 2290: } 2291: } 2292: export async function tryGetPDFReference( 2293: filename: string, 2294: ): Promise<PDFReferenceAttachment | null> { 2295: const ext = parse(filename).ext.toLowerCase() 2296: if (!isPDFExtension(ext)) { 2297: return null 2298: } 2299: try { 2300: const [stats, pageCount] = await Promise.all([ 2301: getFsImplementation().stat(filename), 2302: getPDFPageCount(filename), 2303: ]) 2304: const effectivePageCount = pageCount ?? Math.ceil(stats.size / (100 * 1024)) 2305: if (effectivePageCount > PDF_AT_MENTION_INLINE_THRESHOLD) { 2306: logEvent('tengu_pdf_reference_attachment', { 2307: pageCount: effectivePageCount, 2308: fileSize: stats.size, 2309: hadPdfinfo: pageCount !== null, 2310: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2311: return { 2312: type: 'pdf_reference', 2313: filename, 2314: pageCount: effectivePageCount, 2315: fileSize: stats.size, 2316: displayPath: relative(getCwd(), filename), 2317: } 2318: } 2319: } catch { 2320: } 2321: return null 2322: } 2323: export async function generateFileAttachment( 2324: filename: string, 2325: toolUseContext: ToolUseContext, 2326: successEventName: string, 2327: errorEventName: string, 2328: mode: 'compact' | 'at-mention', 2329: options?: { 2330: offset?: number 2331: limit?: number 2332: }, 2333: ): Promise< 2334: | FileAttachment 2335: | CompactFileReferenceAttachment 2336: | PDFReferenceAttachment 2337: | AlreadyReadFileAttachment 2338: | null 2339: > { 2340: const { offset, limit } = options ?? {} 2341: const appState = toolUseContext.getAppState() 2342: if (isFileReadDenied(filename, appState.toolPermissionContext)) { 2343: return null 2344: } 2345: if ( 2346: mode === 'at-mention' && 2347: !isFileWithinReadSizeLimit( 2348: filename, 2349: getDefaultFileReadingLimits().maxSizeBytes, 2350: ) 2351: ) { 2352: const ext = parse(filename).ext.toLowerCase() 2353: if (!isPDFExtension(ext)) { 2354: try { 2355: const stats = await getFsImplementation().stat(filename) 2356: logEvent('tengu_attachment_file_too_large', { 2357: size_bytes: stats.size, 2358: mode, 2359: } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) 2360: return null 2361: } catch { 2362: } 2363: } 2364: } 2365: if (mode === 'at-mention') { 2366: const pdfRef = await tryGetPDFReference(filename) 2367: if (pdfRef) { 2368: return pdfRef 2369: } 2370: } 2371: const existingFileState = toolUseContext.readFileState.get(filename) 2372: if (existingFileState && mode === 'at-mention') { 2373: try { 2374: const mtimeMs = await getFileModificationTimeAsync(filename) 2375: if ( 2376: existingFileState.timestamp <= mtimeMs && 2377: mtimeMs === existingFileState.timestamp 2378: ) { 2379: logEvent(successEventName, {}) 2380: return { 2381: type: 'already_read_file', 2382: filename, 2383: displayPath: relative(getCwd(), filename), 2384: content: { 2385: type: 'text', 2386: file: { 2387: filePath: filename, 2388: content: existingFileState.content, 2389: numLines: countCharInString(existingFileState.content, '\n') + 1, 2390: startLine: offset ?? 1, 2391: totalLines: 2392: countCharInString(existingFileState.content, '\n') + 1, 2393: }, 2394: }, 2395: } 2396: } 2397: } catch { 2398: } 2399: } 2400: try { 2401: const fileInput = { 2402: file_path: filename, 2403: offset, 2404: limit, 2405: } 2406: async function readTruncatedFile(): Promise< 2407: | FileAttachment 2408: | CompactFileReferenceAttachment 2409: | AlreadyReadFileAttachment 2410: | null 2411: > { 2412: if (mode === 'compact') { 2413: return { 2414: type: 'compact_file_reference', 2415: filename, 2416: displayPath: relative(getCwd(), filename), 2417: } 2418: } 2419: const appState = toolUseContext.getAppState() 2420: if (isFileReadDenied(filename, appState.toolPermissionContext)) { 2421: return null 2422: } 2423: try { 2424: const truncatedInput = { 2425: file_path: filename, 2426: offset: offset ?? 1, 2427: limit: MAX_LINES_TO_READ, 2428: } 2429: const result = await FileReadTool.call(truncatedInput, toolUseContext) 2430: logEvent(successEventName, {}) 2431: return { 2432: type: 'file' as const, 2433: filename, 2434: content: result.data, 2435: truncated: true, 2436: displayPath: relative(getCwd(), filename), 2437: } 2438: } catch { 2439: logEvent(errorEventName, {}) 2440: return null 2441: } 2442: } 2443: const isValid = await FileReadTool.validateInput(fileInput, toolUseContext) 2444: if (!isValid.result) { 2445: return null 2446: } 2447: try { 2448: const result = await FileReadTool.call(fileInput, toolUseContext) 2449: logEvent(successEventName, {}) 2450: return { 2451: type: 'file', 2452: filename, 2453: content: result.data, 2454: displayPath: relative(getCwd(), filename), 2455: } 2456: } catch (error) { 2457: if ( 2458: error instanceof MaxFileReadTokenExceededError || 2459: error instanceof FileTooLargeError 2460: ) { 2461: return await readTruncatedFile() 2462: } 2463: throw error 2464: } 2465: } catch { 2466: logEvent(errorEventName, {}) 2467: return null 2468: } 2469: } 2470: export function createAttachmentMessage( 2471: attachment: Attachment, 2472: ): AttachmentMessage { 2473: return { 2474: attachment, 2475: type: 'attachment', 2476: uuid: randomUUID(), 2477: timestamp: new Date().toISOString(), 2478: } 2479: } 2480: function getTodoReminderTurnCounts(messages: Message[]): { 2481: turnsSinceLastTodoWrite: number 2482: turnsSinceLastReminder: number 2483: } { 2484: let lastTodoWriteIndex = -1 2485: let lastReminderIndex = -1 2486: let assistantTurnsSinceWrite = 0 2487: let assistantTurnsSinceReminder = 0 2488: for (let i = messages.length - 1; i >= 0; i--) { 2489: const message = messages[i] 2490: if (message?.type === 'assistant') { 2491: if (isThinkingMessage(message)) { 2492: continue 2493: } 2494: if ( 2495: lastTodoWriteIndex === -1 && 2496: 'message' in message && 2497: Array.isArray(message.message?.content) && 2498: message.message.content.some( 2499: block => block.type === 'tool_use' && block.name === 'TodoWrite', 2500: ) 2501: ) { 2502: lastTodoWriteIndex = i 2503: } 2504: if (lastTodoWriteIndex === -1) assistantTurnsSinceWrite++ 2505: if (lastReminderIndex === -1) assistantTurnsSinceReminder++ 2506: } else if ( 2507: lastReminderIndex === -1 && 2508: message?.type === 'attachment' && 2509: message.attachment.type === 'todo_reminder' 2510: ) { 2511: lastReminderIndex = i 2512: } 2513: if (lastTodoWriteIndex !== -1 && lastReminderIndex !== -1) { 2514: break 2515: } 2516: } 2517: return { 2518: turnsSinceLastTodoWrite: assistantTurnsSinceWrite, 2519: turnsSinceLastReminder: assistantTurnsSinceReminder, 2520: } 2521: } 2522: async function getTodoReminderAttachments( 2523: messages: Message[] | undefined, 2524: toolUseContext: ToolUseContext, 2525: ): Promise<Attachment[]> { 2526: if ( 2527: !toolUseContext.options.tools.some(t => 2528: toolMatchesName(t, TODO_WRITE_TOOL_NAME), 2529: ) 2530: ) { 2531: return [] 2532: } 2533: if ( 2534: BRIEF_TOOL_NAME && 2535: toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME)) 2536: ) { 2537: return [] 2538: } 2539: if (!messages || messages.length === 0) { 2540: return [] 2541: } 2542: const { turnsSinceLastTodoWrite, turnsSinceLastReminder } = 2543: getTodoReminderTurnCounts(messages) 2544: if ( 2545: turnsSinceLastTodoWrite >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && 2546: turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS 2547: ) { 2548: const todoKey = toolUseContext.agentId ?? getSessionId() 2549: const appState = toolUseContext.getAppState() 2550: const todos = appState.todos[todoKey] ?? [] 2551: return [ 2552: { 2553: type: 'todo_reminder', 2554: content: todos, 2555: itemCount: todos.length, 2556: }, 2557: ] 2558: } 2559: return [] 2560: } 2561: function getTaskReminderTurnCounts(messages: Message[]): { 2562: turnsSinceLastTaskManagement: number 2563: turnsSinceLastReminder: number 2564: } { 2565: let lastTaskManagementIndex = -1 2566: let lastReminderIndex = -1 2567: let assistantTurnsSinceTaskManagement = 0 2568: let assistantTurnsSinceReminder = 0 2569: for (let i = messages.length - 1; i >= 0; i--) { 2570: const message = messages[i] 2571: if (message?.type === 'assistant') { 2572: if (isThinkingMessage(message)) { 2573: continue 2574: } 2575: if ( 2576: lastTaskManagementIndex === -1 && 2577: 'message' in message && 2578: Array.isArray(message.message?.content) && 2579: message.message.content.some( 2580: block => 2581: block.type === 'tool_use' && 2582: (block.name === TASK_CREATE_TOOL_NAME || 2583: block.name === TASK_UPDATE_TOOL_NAME), 2584: ) 2585: ) { 2586: lastTaskManagementIndex = i 2587: } 2588: if (lastTaskManagementIndex === -1) assistantTurnsSinceTaskManagement++ 2589: if (lastReminderIndex === -1) assistantTurnsSinceReminder++ 2590: } else if ( 2591: lastReminderIndex === -1 && 2592: message?.type === 'attachment' && 2593: message.attachment.type === 'task_reminder' 2594: ) { 2595: lastReminderIndex = i 2596: } 2597: if (lastTaskManagementIndex !== -1 && lastReminderIndex !== -1) { 2598: break 2599: } 2600: } 2601: return { 2602: turnsSinceLastTaskManagement: assistantTurnsSinceTaskManagement, 2603: turnsSinceLastReminder: assistantTurnsSinceReminder, 2604: } 2605: } 2606: async function getTaskReminderAttachments( 2607: messages: Message[] | undefined, 2608: toolUseContext: ToolUseContext, 2609: ): Promise<Attachment[]> { 2610: if (!isTodoV2Enabled()) { 2611: return [] 2612: } 2613: if (process.env.USER_TYPE === 'ant') { 2614: return [] 2615: } 2616: if ( 2617: BRIEF_TOOL_NAME && 2618: toolUseContext.options.tools.some(t => toolMatchesName(t, BRIEF_TOOL_NAME)) 2619: ) { 2620: return [] 2621: } 2622: if ( 2623: !toolUseContext.options.tools.some(t => 2624: toolMatchesName(t, TASK_UPDATE_TOOL_NAME), 2625: ) 2626: ) { 2627: return [] 2628: } 2629: if (!messages || messages.length === 0) { 2630: return [] 2631: } 2632: const { turnsSinceLastTaskManagement, turnsSinceLastReminder } = 2633: getTaskReminderTurnCounts(messages) 2634: if ( 2635: turnsSinceLastTaskManagement >= TODO_REMINDER_CONFIG.TURNS_SINCE_WRITE && 2636: turnsSinceLastReminder >= TODO_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS 2637: ) { 2638: const tasks = await listTasks(getTaskListId()) 2639: return [ 2640: { 2641: type: 'task_reminder', 2642: content: tasks, 2643: itemCount: tasks.length, 2644: }, 2645: ] 2646: } 2647: return [] 2648: } 2649: async function getUnifiedTaskAttachments( 2650: toolUseContext: ToolUseContext, 2651: ): Promise<Attachment[]> { 2652: const appState = toolUseContext.getAppState() 2653: const { attachments, updatedTaskOffsets, evictedTaskIds } = 2654: await generateTaskAttachments(appState) 2655: applyTaskOffsetsAndEvictions( 2656: toolUseContext.setAppState, 2657: updatedTaskOffsets, 2658: evictedTaskIds, 2659: ) 2660: return attachments.map(taskAttachment => ({ 2661: type: 'task_status' as const, 2662: taskId: taskAttachment.taskId, 2663: taskType: taskAttachment.taskType, 2664: status: taskAttachment.status, 2665: description: taskAttachment.description, 2666: deltaSummary: taskAttachment.deltaSummary, 2667: outputFilePath: getTaskOutputPath(taskAttachment.taskId), 2668: })) 2669: } 2670: async function getAsyncHookResponseAttachments(): Promise<Attachment[]> { 2671: const responses = await checkForAsyncHookResponses() 2672: if (responses.length === 0) { 2673: return [] 2674: } 2675: logForDebugging( 2676: `Hooks: getAsyncHookResponseAttachments found ${responses.length} responses`, 2677: ) 2678: const attachments = responses.map( 2679: ({ 2680: processId, 2681: response, 2682: hookName, 2683: hookEvent, 2684: toolName, 2685: pluginId, 2686: stdout, 2687: stderr, 2688: exitCode, 2689: }) => { 2690: logForDebugging( 2691: `Hooks: Creating attachment for ${processId} (${hookName}): ${jsonStringify(response)}`, 2692: ) 2693: return { 2694: type: 'async_hook_response' as const, 2695: processId, 2696: hookName, 2697: hookEvent, 2698: toolName, 2699: response, 2700: stdout, 2701: stderr, 2702: exitCode, 2703: } 2704: }, 2705: ) 2706: if (responses.length > 0) { 2707: const processIds = responses.map(r => r.processId) 2708: removeDeliveredAsyncHooks(processIds) 2709: logForDebugging( 2710: `Hooks: Removed ${processIds.length} delivered hooks from registry`, 2711: ) 2712: } 2713: logForDebugging( 2714: `Hooks: getAsyncHookResponseAttachments found ${attachments.length} attachments`, 2715: ) 2716: return attachments 2717: } 2718: async function getTeammateMailboxAttachments( 2719: toolUseContext: ToolUseContext, 2720: ): Promise<Attachment[]> { 2721: if (!isAgentSwarmsEnabled()) { 2722: return [] 2723: } 2724: if (process.env.USER_TYPE !== 'ant') { 2725: return [] 2726: } 2727: const appState = toolUseContext.getAppState() 2728: const envAgentName = getAgentName() 2729: const teamName = getTeamName(appState.teamContext) 2730: const teamLeadStatus = isTeamLead(appState.teamContext) 2731: const viewedTeammate = getViewedTeammateTask(appState) 2732: let agentName = viewedTeammate?.identity.agentName ?? envAgentName 2733: if (!agentName && teamLeadStatus && appState.teamContext) { 2734: const leadAgentId = appState.teamContext.leadAgentId 2735: agentName = appState.teamContext.teammates[leadAgentId]?.name || 'team-lead' 2736: } 2737: logForDebugging( 2738: `[SwarmMailbox] getTeammateMailboxAttachments called: envAgentName=${envAgentName}, isTeamLead=${teamLeadStatus}, resolved agentName=${agentName}, teamName=${teamName}`, 2739: ) 2740: if (!agentName) { 2741: logForDebugging( 2742: `[SwarmMailbox] Not checking inbox - not in a swarm or team lead`, 2743: ) 2744: return [] 2745: } 2746: logForDebugging( 2747: `[SwarmMailbox] Checking inbox for agent="${agentName}" team="${teamName || 'default'}"`, 2748: ) 2749: const allUnreadMessages = await readUnreadMessages(agentName, teamName) 2750: const unreadMessages = allUnreadMessages.filter( 2751: m => !isStructuredProtocolMessage(m.text), 2752: ) 2753: logForDebugging( 2754: `[MailboxBridge] Found ${allUnreadMessages.length} unread message(s) for "${agentName}" (${allUnreadMessages.length - unreadMessages.length} structured protocol messages filtered out)`, 2755: ) 2756: const pendingInboxMessages = 2757: viewedTeammate || isInProcessTeammate() 2758: ? [] 2759: : appState.inbox.messages.filter(m => m.status === 'pending') 2760: logForDebugging( 2761: `[SwarmMailbox] Found ${pendingInboxMessages.length} pending message(s) in AppState.inbox`, 2762: ) 2763: const seen = new Set<string>() 2764: let allMessages: Array<{ 2765: from: string 2766: text: string 2767: timestamp: string 2768: color?: string 2769: summary?: string 2770: }> = [] 2771: for (const m of [...unreadMessages, ...pendingInboxMessages]) { 2772: const key = `${m.from}|${m.timestamp}|${m.text.slice(0, 100)}` 2773: if (!seen.has(key)) { 2774: seen.add(key) 2775: allMessages.push({ 2776: from: m.from, 2777: text: m.text, 2778: timestamp: m.timestamp, 2779: color: m.color, 2780: summary: m.summary, 2781: }) 2782: } 2783: } 2784: const idleAgentByIndex = new Map<number, string>() 2785: const latestIdleByAgent = new Map<string, number>() 2786: for (let i = 0; i < allMessages.length; i++) { 2787: const idle = isIdleNotification(allMessages[i]!.text) 2788: if (idle) { 2789: idleAgentByIndex.set(i, idle.from) 2790: latestIdleByAgent.set(idle.from, i) 2791: } 2792: } 2793: if (idleAgentByIndex.size > latestIdleByAgent.size) { 2794: const beforeCount = allMessages.length 2795: allMessages = allMessages.filter((_m, i) => { 2796: const agent = idleAgentByIndex.get(i) 2797: if (agent === undefined) return true 2798: return latestIdleByAgent.get(agent) === i 2799: }) 2800: logForDebugging( 2801: `[SwarmMailbox] Collapsed ${beforeCount - allMessages.length} duplicate idle notification(s)`, 2802: ) 2803: } 2804: if (allMessages.length === 0) { 2805: logForDebugging(`[SwarmMailbox] No messages to deliver, returning empty`) 2806: return [] 2807: } 2808: logForDebugging( 2809: `[SwarmMailbox] Returning ${allMessages.length} message(s) as attachment for "${agentName}" (${unreadMessages.length} from file, ${pendingInboxMessages.length} from AppState, after dedup)`, 2810: ) 2811: const attachment: Attachment[] = [ 2812: { 2813: type: 'teammate_mailbox', 2814: messages: allMessages, 2815: }, 2816: ] 2817: if (unreadMessages.length > 0) { 2818: await markMessagesAsReadByPredicate( 2819: agentName, 2820: m => !isStructuredProtocolMessage(m.text), 2821: teamName, 2822: ) 2823: logForDebugging( 2824: `[MailboxBridge] marked ${unreadMessages.length} non-structured message(s) as read for agent="${agentName}" team="${teamName || 'default'}"`, 2825: ) 2826: } 2827: if (teamLeadStatus && teamName) { 2828: for (const m of allMessages) { 2829: const shutdownApproval = isShutdownApproved(m.text) 2830: if (shutdownApproval) { 2831: const teammateToRemove = shutdownApproval.from 2832: logForDebugging( 2833: `[SwarmMailbox] Processing shutdown_approved from ${teammateToRemove}`, 2834: ) 2835: const teammateId = appState.teamContext?.teammates 2836: ? Object.entries(appState.teamContext.teammates).find( 2837: ([, t]) => t.name === teammateToRemove, 2838: )?.[0] 2839: : undefined 2840: if (teammateId) { 2841: removeTeammateFromTeamFile(teamName, { 2842: agentId: teammateId, 2843: name: teammateToRemove, 2844: }) 2845: logForDebugging( 2846: `[SwarmMailbox] Removed ${teammateToRemove} from team file`, 2847: ) 2848: await unassignTeammateTasks( 2849: teamName, 2850: teammateId, 2851: teammateToRemove, 2852: 'shutdown', 2853: ) 2854: toolUseContext.setAppState(prev => { 2855: if (!prev.teamContext?.teammates) return prev 2856: if (!(teammateId in prev.teamContext.teammates)) return prev 2857: const { [teammateId]: _, ...remainingTeammates } = 2858: prev.teamContext.teammates 2859: return { 2860: ...prev, 2861: teamContext: { 2862: ...prev.teamContext, 2863: teammates: remainingTeammates, 2864: }, 2865: } 2866: }) 2867: } 2868: } 2869: } 2870: } 2871: if (pendingInboxMessages.length > 0) { 2872: const pendingIds = new Set(pendingInboxMessages.map(m => m.id)) 2873: toolUseContext.setAppState(prev => ({ 2874: ...prev, 2875: inbox: { 2876: messages: prev.inbox.messages.map(m => 2877: pendingIds.has(m.id) ? { ...m, status: 'processed' as const } : m, 2878: ), 2879: }, 2880: })) 2881: } 2882: return attachment 2883: } 2884: function getTeamContextAttachment(messages: Message[]): Attachment[] { 2885: const teamName = getTeamName() 2886: const agentId = getAgentId() 2887: const agentName = getAgentName() 2888: if (!teamName || !agentId) { 2889: return [] 2890: } 2891: const hasAssistantMessage = messages.some(m => m.type === 'assistant') 2892: if (hasAssistantMessage) { 2893: return [] 2894: } 2895: const configDir = getClaudeConfigHomeDir() 2896: const teamConfigPath = `${configDir}/teams/${teamName}/config.json` 2897: const taskListPath = `${configDir}/tasks/${teamName}/` 2898: return [ 2899: { 2900: type: 'team_context', 2901: agentId, 2902: agentName: agentName || agentId, 2903: teamName, 2904: teamConfigPath, 2905: taskListPath, 2906: }, 2907: ] 2908: } 2909: function getTokenUsageAttachment( 2910: messages: Message[], 2911: model: string, 2912: ): Attachment[] { 2913: if (!isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_TOKEN_USAGE_ATTACHMENT)) { 2914: return [] 2915: } 2916: const contextWindow = getEffectiveContextWindowSize(model) 2917: const usedTokens = tokenCountFromLastAPIResponse(messages) 2918: return [ 2919: { 2920: type: 'token_usage', 2921: used: usedTokens, 2922: total: contextWindow, 2923: remaining: contextWindow - usedTokens, 2924: }, 2925: ] 2926: } 2927: function getOutputTokenUsageAttachment(): Attachment[] { 2928: if (feature('TOKEN_BUDGET')) { 2929: const budget = getCurrentTurnTokenBudget() 2930: if (budget === null || budget <= 0) { 2931: return [] 2932: } 2933: return [ 2934: { 2935: type: 'output_token_usage', 2936: turn: getTurnOutputTokens(), 2937: session: getTotalOutputTokens(), 2938: budget, 2939: }, 2940: ] 2941: } 2942: return [] 2943: } 2944: function getMaxBudgetUsdAttachment(maxBudgetUsd?: number): Attachment[] { 2945: if (maxBudgetUsd === undefined) { 2946: return [] 2947: } 2948: const usedCost = getTotalCostUSD() 2949: const remainingBudget = maxBudgetUsd - usedCost 2950: return [ 2951: { 2952: type: 'budget_usd', 2953: used: usedCost, 2954: total: maxBudgetUsd, 2955: remaining: remainingBudget, 2956: }, 2957: ] 2958: } 2959: export function getVerifyPlanReminderTurnCount(messages: Message[]): number { 2960: let turnCount = 0 2961: for (let i = messages.length - 1; i >= 0; i--) { 2962: const message = messages[i] 2963: if (message && isHumanTurn(message)) { 2964: turnCount++ 2965: } 2966: if ( 2967: message?.type === 'attachment' && 2968: message.attachment.type === 'plan_mode_exit' 2969: ) { 2970: return turnCount 2971: } 2972: } 2973: return 0 2974: } 2975: async function getVerifyPlanReminderAttachment( 2976: messages: Message[] | undefined, 2977: toolUseContext: ToolUseContext, 2978: ): Promise<Attachment[]> { 2979: if ( 2980: process.env.USER_TYPE !== 'ant' || 2981: !isEnvTruthy(process.env.CLAUDE_CODE_VERIFY_PLAN) 2982: ) { 2983: return [] 2984: } 2985: const appState = toolUseContext.getAppState() 2986: const pending = appState.pendingPlanVerification 2987: if ( 2988: !pending || 2989: pending.verificationStarted || 2990: pending.verificationCompleted 2991: ) { 2992: return [] 2993: } 2994: if (messages && messages.length > 0) { 2995: const turnCount = getVerifyPlanReminderTurnCount(messages) 2996: if ( 2997: turnCount === 0 || 2998: turnCount % VERIFY_PLAN_REMINDER_CONFIG.TURNS_BETWEEN_REMINDERS !== 0 2999: ) { 3000: return [] 3001: } 3002: } 3003: return [{ type: 'verify_plan_reminder' }] 3004: } 3005: export function getCompactionReminderAttachment( 3006: messages: Message[], 3007: model: string, 3008: ): Attachment[] { 3009: if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_fox', false)) { 3010: return [] 3011: } 3012: if (!isAutoCompactEnabled()) { 3013: return [] 3014: } 3015: const contextWindow = getContextWindowForModel(model, getSdkBetas()) 3016: if (contextWindow < 1_000_000) { 3017: return [] 3018: } 3019: const effectiveWindow = getEffectiveContextWindowSize(model) 3020: const usedTokens = tokenCountWithEstimation(messages) 3021: if (usedTokens < effectiveWindow * 0.25) { 3022: return [] 3023: } 3024: return [{ type: 'compaction_reminder' }] 3025: } 3026: export function getContextEfficiencyAttachment( 3027: messages: Message[], 3028: ): Attachment[] { 3029: if (!feature('HISTORY_SNIP')) { 3030: return [] 3031: } 3032: const { isSnipRuntimeEnabled, shouldNudgeForSnips } = 3033: require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js') 3034: if (!isSnipRuntimeEnabled()) { 3035: return [] 3036: } 3037: if (!shouldNudgeForSnips(messages)) { 3038: return [] 3039: } 3040: return [{ type: 'context_efficiency' }] 3041: } 3042: function isFileReadDenied( 3043: filePath: string, 3044: toolPermissionContext: ToolPermissionContext, 3045: ): boolean { 3046: const denyRule = matchingRuleForInput( 3047: filePath, 3048: toolPermissionContext, 3049: 'read', 3050: 'deny', 3051: ) 3052: return denyRule !== null 3053: }

File: src/utils/attribution.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { stat } from 'fs/promises' 3: import { getClientType } from '../bootstrap/state.js' 4: import { 5: getRemoteSessionUrl, 6: isRemoteSessionLocal, 7: PRODUCT_URL, 8: } from '../constants/product.js' 9: import { TERMINAL_OUTPUT_TAGS } from '../constants/xml.js' 10: import type { AppState } from '../state/AppState.js' 11: import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js' 12: import { FILE_READ_TOOL_NAME } from '../tools/FileReadTool/prompt.js' 13: import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js' 14: import { GLOB_TOOL_NAME } from '../tools/GlobTool/prompt.js' 15: import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js' 16: import type { Entry } from '../types/logs.js' 17: import { 18: type AttributionData, 19: calculateCommitAttribution, 20: isInternalModelRepo, 21: isInternalModelRepoCached, 22: sanitizeModelName, 23: } from './commitAttribution.js' 24: import { logForDebugging } from './debug.js' 25: import { parseJSONL } from './json.js' 26: import { logError } from './log.js' 27: import { 28: getCanonicalName, 29: getMainLoopModel, 30: getPublicModelDisplayName, 31: getPublicModelName, 32: } from './model/model.js' 33: import { isMemoryFileAccess } from './sessionFileAccessHooks.js' 34: import { getTranscriptPath } from './sessionStorage.js' 35: import { readTranscriptForLoad } from './sessionStoragePortable.js' 36: import { getInitialSettings } from './settings/settings.js' 37: import { isUndercover } from './undercover.js' 38: export type AttributionTexts = { 39: commit: string 40: pr: string 41: } 42: export function getAttributionTexts(): AttributionTexts { 43: if (process.env.USER_TYPE === 'ant' && isUndercover()) { 44: return { commit: '', pr: '' } 45: } 46: if (getClientType() === 'remote') { 47: const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID 48: if (remoteSessionId) { 49: const ingressUrl = process.env.SESSION_INGRESS_URL 50: if (!isRemoteSessionLocal(remoteSessionId, ingressUrl)) { 51: const sessionUrl = getRemoteSessionUrl(remoteSessionId, ingressUrl) 52: return { commit: sessionUrl, pr: sessionUrl } 53: } 54: } 55: return { commit: '', pr: '' } 56: } 57: // @[MODEL LAUNCH]: Update the hardcoded fallback model name below (guards against codename leaks). 58: // For internal repos, use the real model name. For external repos, 59: // fall back to "Claude Opus 4.6" for unrecognized models to avoid leaking codenames. 60: const model = getMainLoopModel() 61: const isKnownPublicModel = getPublicModelDisplayName(model) !== null 62: const modelName = 63: isInternalModelRepoCached() || isKnownPublicModel 64: ? getPublicModelName(model) 65: : 'Claude Opus 4.6' 66: const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})` 67: const defaultCommit = `Co-Authored-By: ${modelName} <noreply@anthropic.com>` 68: const settings = getInitialSettings() 69: if (settings.attribution) { 70: return { 71: commit: settings.attribution.commit ?? defaultCommit, 72: pr: settings.attribution.pr ?? defaultAttribution, 73: } 74: } 75: if (settings.includeCoAuthoredBy === false) { 76: return { commit: '', pr: '' } 77: } 78: return { commit: defaultCommit, pr: defaultAttribution } 79: } 80: /** 81: * Check if a message content string is terminal output rather than a user prompt. 82: * Terminal output includes bash input/output tags and caveat messages about local commands. 83: */ 84: function isTerminalOutput(content: string): boolean { 85: for (const tag of TERMINAL_OUTPUT_TAGS) { 86: if (content.includes(`<${tag}>`)) { 87: return true 88: } 89: } 90: return false 91: } 92: /** 93: * Count user messages with visible text content in a list of non-sidechain messages. 94: * Excludes tool_result blocks, terminal output, and empty messages. 95: * 96: * Callers should pass messages already filtered to exclude sidechain messages. 97: */ 98: export function countUserPromptsInMessages( 99: messages: ReadonlyArray<{ type: string; message?: { content?: unknown } }>, 100: ): number { 101: let count = 0 102: for (const message of messages) { 103: if (message.type !== 'user') { 104: continue 105: } 106: const content = message.message?.content 107: if (!content) { 108: continue 109: } 110: let hasUserText = false 111: if (typeof content === 'string') { 112: if (isTerminalOutput(content)) { 113: continue 114: } 115: hasUserText = content.trim().length > 0 116: } else if (Array.isArray(content)) { 117: hasUserText = content.some(block => { 118: if (!block || typeof block !== 'object' || !('type' in block)) { 119: return false 120: } 121: return ( 122: (block.type === 'text' && 123: typeof block.text === 'string' && 124: !isTerminalOutput(block.text)) || 125: block.type === 'image' || 126: block.type === 'document' 127: ) 128: }) 129: } 130: if (hasUserText) { 131: count++ 132: } 133: } 134: return count 135: } 136: function countUserPromptsFromEntries(entries: ReadonlyArray<Entry>): number { 137: const nonSidechain = entries.filter( 138: entry => 139: entry.type === 'user' && !('isSidechain' in entry && entry.isSidechain), 140: ) 141: return countUserPromptsInMessages(nonSidechain) 142: } 143: async function getPRAttributionData( 144: appState: AppState, 145: ): Promise<AttributionData | null> { 146: const attribution = appState.attribution 147: if (!attribution) { 148: return null 149: } 150: const fileStates = attribution.fileStates 151: const isMap = fileStates instanceof Map 152: const trackedFiles = isMap 153: ? Array.from(fileStates.keys()) 154: : Object.keys(fileStates) 155: if (trackedFiles.length === 0) { 156: return null 157: } 158: try { 159: return await calculateCommitAttribution([attribution], trackedFiles) 160: } catch (error) { 161: logError(error as Error) 162: return null 163: } 164: } 165: const MEMORY_ACCESS_TOOL_NAMES = new Set([ 166: FILE_READ_TOOL_NAME, 167: GREP_TOOL_NAME, 168: GLOB_TOOL_NAME, 169: FILE_EDIT_TOOL_NAME, 170: FILE_WRITE_TOOL_NAME, 171: ]) 172: function countMemoryFileAccessFromEntries( 173: entries: ReadonlyArray<Entry>, 174: ): number { 175: let count = 0 176: for (const entry of entries) { 177: if (entry.type !== 'assistant') continue 178: const content = entry.message?.content 179: if (!Array.isArray(content)) continue 180: for (const block of content) { 181: if ( 182: block.type !== 'tool_use' || 183: !MEMORY_ACCESS_TOOL_NAMES.has(block.name) 184: ) 185: continue 186: if (isMemoryFileAccess(block.name, block.input)) count++ 187: } 188: } 189: return count 190: } 191: async function getTranscriptStats(): Promise<{ 192: promptCount: number 193: memoryAccessCount: number 194: }> { 195: try { 196: const filePath = getTranscriptPath() 197: const fileSize = (await stat(filePath)).size 198: const scan = await readTranscriptForLoad(filePath, fileSize) 199: const buf = scan.postBoundaryBuf 200: const entries = parseJSONL<Entry>(buf) 201: const lastBoundaryIdx = entries.findLastIndex( 202: e => 203: e.type === 'system' && 204: 'subtype' in e && 205: e.subtype === 'compact_boundary', 206: ) 207: const postBoundary = 208: lastBoundaryIdx >= 0 ? entries.slice(lastBoundaryIdx + 1) : entries 209: return { 210: promptCount: countUserPromptsFromEntries(postBoundary), 211: memoryAccessCount: countMemoryFileAccessFromEntries(postBoundary), 212: } 213: } catch { 214: return { promptCount: 0, memoryAccessCount: 0 } 215: } 216: } 217: export async function getEnhancedPRAttribution( 218: getAppState: () => AppState, 219: ): Promise<string> { 220: if (process.env.USER_TYPE === 'ant' && isUndercover()) { 221: return '' 222: } 223: if (getClientType() === 'remote') { 224: const remoteSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID 225: if (remoteSessionId) { 226: const ingressUrl = process.env.SESSION_INGRESS_URL 227: if (!isRemoteSessionLocal(remoteSessionId, ingressUrl)) { 228: return getRemoteSessionUrl(remoteSessionId, ingressUrl) 229: } 230: } 231: return '' 232: } 233: const settings = getInitialSettings() 234: // If user has custom PR attribution, use that 235: if (settings.attribution?.pr) { 236: return settings.attribution.pr 237: } 238: // Backward compatibility: deprecated includeCoAuthoredBy setting 239: if (settings.includeCoAuthoredBy === false) { 240: return '' 241: } 242: const defaultAttribution = `🤖 Generated with [Claude Code](${PRODUCT_URL})` 243: // Get AppState first 244: const appState = getAppState() 245: logForDebugging( 246: `PR Attribution: appState.attribution exists: ${!!appState.attribution}`, 247: ) 248: if (appState.attribution) { 249: const fileStates = appState.attribution.fileStates 250: const isMap = fileStates instanceof Map 251: const fileCount = isMap ? fileStates.size : Object.keys(fileStates).length 252: logForDebugging(`PR Attribution: fileStates count: ${fileCount}`) 253: } 254: // Get attribution stats (transcript is read once for both prompt count and memory access) 255: const [attributionData, { promptCount, memoryAccessCount }, isInternal] = 256: await Promise.all([ 257: getPRAttributionData(appState), 258: getTranscriptStats(), 259: isInternalModelRepo(), 260: ]) 261: const claudePercent = attributionData?.summary.claudePercent ?? 0 262: logForDebugging( 263: `PR Attribution: claudePercent: ${claudePercent}, promptCount: ${promptCount}, memoryAccessCount: ${memoryAccessCount}`, 264: ) 265: // Get short model name, sanitized for non-internal repos 266: const rawModelName = getCanonicalName(getMainLoopModel()) 267: const shortModelName = isInternal 268: ? rawModelName 269: : sanitizeModelName(rawModelName) 270: // If no attribution data, return default 271: if (claudePercent === 0 && promptCount === 0 && memoryAccessCount === 0) { 272: logForDebugging('PR Attribution: returning default (no data)') 273: return defaultAttribution 274: } 275: // Build the enhanced attribution: "🤖 Generated with Claude Code (93% 3-shotted by claude-opus-4-5, 2 memories recalled)" 276: const memSuffix = 277: memoryAccessCount > 0 278: ? `, ${memoryAccessCount} ${memoryAccessCount === 1 ? 'memory' : 'memories'} recalled` 279: : '' 280: const summary = `🤖 Generated with [Claude Code](${PRODUCT_URL}) (${claudePercent}% ${promptCount}-shotted by ${shortModelName}${memSuffix})` 281: // Append trailer lines for squash-merge survival. Only for allowlisted repos 282: // (INTERNAL_MODEL_REPOS) and only in builds with COMMIT_ATTRIBUTION enabled — 283: // attributionTrailer.ts contains excluded strings, so reach it via dynamic 284: // import behind feature(). When the repo is configured with 285: // squash_merge_commit_message=PR_BODY (cli, apps), the PR body becomes the 286: // squash commit body verbatim — trailer lines at the end become proper git 287: // trailers on the squash commit. 288: if (feature('COMMIT_ATTRIBUTION') && isInternal && attributionData) { 289: const { buildPRTrailers } = await import('./attributionTrailer.js') 290: const trailers = buildPRTrailers(attributionData, appState.attribution) 291: const result = `${summary}\n\n${trailers.join('\n')}` 292: logForDebugging(`PR Attribution: returning with trailers: ${result}`) 293: return result 294: } 295: logForDebugging(`PR Attribution: returning summary: ${summary}`) 296: return summary 297: }

File: src/utils/auth.ts

typescript 1: import chalk from 'chalk' 2: import { exec } from 'child_process' 3: import { execa } from 'execa' 4: import { mkdir, stat } from 'fs/promises' 5: import memoize from 'lodash-es/memoize.js' 6: import { join } from 'path' 7: import { CLAUDE_AI_PROFILE_SCOPE } from 'src/constants/oauth.js' 8: import { 9: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 10: logEvent, 11: } from 'src/services/analytics/index.js' 12: import { getModelStrings } from 'src/utils/model/modelStrings.js' 13: import { getAPIProvider } from 'src/utils/model/providers.js' 14: import { 15: getIsNonInteractiveSession, 16: preferThirdPartyAuthentication, 17: } from '../bootstrap/state.js' 18: import { 19: getMockSubscriptionType, 20: shouldUseMockSubscription, 21: } from '../services/mockRateLimits.js' 22: import { 23: isOAuthTokenExpired, 24: refreshOAuthToken, 25: shouldUseClaudeAIAuth, 26: } from '../services/oauth/client.js' 27: import { getOauthProfileFromOauthToken } from '../services/oauth/getOauthProfile.js' 28: import type { OAuthTokens, SubscriptionType } from '../services/oauth/types.js' 29: import { 30: getApiKeyFromFileDescriptor, 31: getOAuthTokenFromFileDescriptor, 32: } from './authFileDescriptor.js' 33: import { 34: maybeRemoveApiKeyFromMacOSKeychainThrows, 35: normalizeApiKeyForConfig, 36: } from './authPortable.js' 37: import { 38: checkStsCallerIdentity, 39: clearAwsIniCache, 40: isValidAwsStsOutput, 41: } from './aws.js' 42: import { AwsAuthStatusManager } from './awsAuthStatusManager.js' 43: import { clearBetasCaches } from './betas.js' 44: import { 45: type AccountInfo, 46: checkHasTrustDialogAccepted, 47: getGlobalConfig, 48: saveGlobalConfig, 49: } from './config.js' 50: import { logAntError, logForDebugging } from './debug.js' 51: import { 52: getClaudeConfigHomeDir, 53: isBareMode, 54: isEnvTruthy, 55: isRunningOnHomespace, 56: } from './envUtils.js' 57: import { errorMessage } from './errors.js' 58: import { execSyncWithDefaults_DEPRECATED } from './execFileNoThrow.js' 59: import * as lockfile from './lockfile.js' 60: import { logError } from './log.js' 61: import { memoizeWithTTLAsync } from './memoize.js' 62: import { getSecureStorage } from './secureStorage/index.js' 63: import { 64: clearLegacyApiKeyPrefetch, 65: getLegacyApiKeyPrefetchResult, 66: } from './secureStorage/keychainPrefetch.js' 67: import { 68: clearKeychainCache, 69: getMacOsKeychainStorageServiceName, 70: getUsername, 71: } from './secureStorage/macOsKeychainHelpers.js' 72: import { 73: getSettings_DEPRECATED, 74: getSettingsForSource, 75: } from './settings/settings.js' 76: import { sleep } from './sleep.js' 77: import { jsonParse } from './slowOperations.js' 78: import { clearToolSchemaCache } from './toolSchemaCache.js' 79: const DEFAULT_API_KEY_HELPER_TTL = 5 * 60 * 1000 80: function isManagedOAuthContext(): boolean { 81: return ( 82: isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) || 83: process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop' 84: ) 85: } 86: export function isAnthropicAuthEnabled(): boolean { 87: if (isBareMode()) return false 88: if (process.env.ANTHROPIC_UNIX_SOCKET) { 89: return !!process.env.CLAUDE_CODE_OAUTH_TOKEN 90: } 91: const is3P = 92: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 93: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 94: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) 95: const settings = getSettings_DEPRECATED() || {} 96: const apiKeyHelper = settings.apiKeyHelper 97: const hasExternalAuthToken = 98: process.env.ANTHROPIC_AUTH_TOKEN || 99: apiKeyHelper || 100: process.env.CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR 101: const { source: apiKeySource } = getAnthropicApiKeyWithSource({ 102: skipRetrievingKeyFromApiKeyHelper: true, 103: }) 104: const hasExternalApiKey = 105: apiKeySource === 'ANTHROPIC_API_KEY' || apiKeySource === 'apiKeyHelper' 106: const shouldDisableAuth = 107: is3P || 108: (hasExternalAuthToken && !isManagedOAuthContext()) || 109: (hasExternalApiKey && !isManagedOAuthContext()) 110: return !shouldDisableAuth 111: } 112: export function getAuthTokenSource() { 113: if (isBareMode()) { 114: if (getConfiguredApiKeyHelper()) { 115: return { source: 'apiKeyHelper' as const, hasToken: true } 116: } 117: return { source: 'none' as const, hasToken: false } 118: } 119: if (process.env.ANTHROPIC_AUTH_TOKEN && !isManagedOAuthContext()) { 120: return { source: 'ANTHROPIC_AUTH_TOKEN' as const, hasToken: true } 121: } 122: if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { 123: return { source: 'CLAUDE_CODE_OAUTH_TOKEN' as const, hasToken: true } 124: } 125: const oauthTokenFromFd = getOAuthTokenFromFileDescriptor() 126: if (oauthTokenFromFd) { 127: if (process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR) { 128: return { 129: source: 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR' as const, 130: hasToken: true, 131: } 132: } 133: return { 134: source: 'CCR_OAUTH_TOKEN_FILE' as const, 135: hasToken: true, 136: } 137: } 138: const apiKeyHelper = getConfiguredApiKeyHelper() 139: if (apiKeyHelper && !isManagedOAuthContext()) { 140: return { source: 'apiKeyHelper' as const, hasToken: true } 141: } 142: const oauthTokens = getClaudeAIOAuthTokens() 143: if (shouldUseClaudeAIAuth(oauthTokens?.scopes) && oauthTokens?.accessToken) { 144: return { source: 'claude.ai' as const, hasToken: true } 145: } 146: return { source: 'none' as const, hasToken: false } 147: } 148: export type ApiKeySource = 149: | 'ANTHROPIC_API_KEY' 150: | 'apiKeyHelper' 151: | '/login managed key' 152: | 'none' 153: export function getAnthropicApiKey(): null | string { 154: const { key } = getAnthropicApiKeyWithSource() 155: return key 156: } 157: export function hasAnthropicApiKeyAuth(): boolean { 158: const { key, source } = getAnthropicApiKeyWithSource({ 159: skipRetrievingKeyFromApiKeyHelper: true, 160: }) 161: return key !== null && source !== 'none' 162: } 163: export function getAnthropicApiKeyWithSource( 164: opts: { skipRetrievingKeyFromApiKeyHelper?: boolean } = {}, 165: ): { 166: key: null | string 167: source: ApiKeySource 168: } { 169: if (isBareMode()) { 170: if (process.env.ANTHROPIC_API_KEY) { 171: return { key: process.env.ANTHROPIC_API_KEY, source: 'ANTHROPIC_API_KEY' } 172: } 173: if (getConfiguredApiKeyHelper()) { 174: return { 175: key: opts.skipRetrievingKeyFromApiKeyHelper 176: ? null 177: : getApiKeyFromApiKeyHelperCached(), 178: source: 'apiKeyHelper', 179: } 180: } 181: return { key: null, source: 'none' } 182: } 183: const apiKeyEnv = isRunningOnHomespace() 184: ? undefined 185: : process.env.ANTHROPIC_API_KEY 186: if (preferThirdPartyAuthentication() && apiKeyEnv) { 187: return { 188: key: apiKeyEnv, 189: source: 'ANTHROPIC_API_KEY', 190: } 191: } 192: if (isEnvTruthy(process.env.CI) || process.env.NODE_ENV === 'test') { 193: const apiKeyFromFd = getApiKeyFromFileDescriptor() 194: if (apiKeyFromFd) { 195: return { 196: key: apiKeyFromFd, 197: source: 'ANTHROPIC_API_KEY', 198: } 199: } 200: if ( 201: !apiKeyEnv && 202: !process.env.CLAUDE_CODE_OAUTH_TOKEN && 203: !process.env.CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR 204: ) { 205: throw new Error( 206: 'ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN env var is required', 207: ) 208: } 209: if (apiKeyEnv) { 210: return { 211: key: apiKeyEnv, 212: source: 'ANTHROPIC_API_KEY', 213: } 214: } 215: return { 216: key: null, 217: source: 'none', 218: } 219: } 220: if ( 221: apiKeyEnv && 222: getGlobalConfig().customApiKeyResponses?.approved?.includes( 223: normalizeApiKeyForConfig(apiKeyEnv), 224: ) 225: ) { 226: return { 227: key: apiKeyEnv, 228: source: 'ANTHROPIC_API_KEY', 229: } 230: } 231: const apiKeyFromFd = getApiKeyFromFileDescriptor() 232: if (apiKeyFromFd) { 233: return { 234: key: apiKeyFromFd, 235: source: 'ANTHROPIC_API_KEY', 236: } 237: } 238: const apiKeyHelperCommand = getConfiguredApiKeyHelper() 239: if (apiKeyHelperCommand) { 240: if (opts.skipRetrievingKeyFromApiKeyHelper) { 241: return { 242: key: null, 243: source: 'apiKeyHelper', 244: } 245: } 246: return { 247: key: getApiKeyFromApiKeyHelperCached(), 248: source: 'apiKeyHelper', 249: } 250: } 251: const apiKeyFromConfigOrMacOSKeychain = getApiKeyFromConfigOrMacOSKeychain() 252: if (apiKeyFromConfigOrMacOSKeychain) { 253: return apiKeyFromConfigOrMacOSKeychain 254: } 255: return { 256: key: null, 257: source: 'none', 258: } 259: } 260: export function getConfiguredApiKeyHelper(): string | undefined { 261: if (isBareMode()) { 262: return getSettingsForSource('flagSettings')?.apiKeyHelper 263: } 264: const mergedSettings = getSettings_DEPRECATED() || {} 265: return mergedSettings.apiKeyHelper 266: } 267: function isApiKeyHelperFromProjectOrLocalSettings(): boolean { 268: const apiKeyHelper = getConfiguredApiKeyHelper() 269: if (!apiKeyHelper) { 270: return false 271: } 272: const projectSettings = getSettingsForSource('projectSettings') 273: const localSettings = getSettingsForSource('localSettings') 274: return ( 275: projectSettings?.apiKeyHelper === apiKeyHelper || 276: localSettings?.apiKeyHelper === apiKeyHelper 277: ) 278: } 279: function getConfiguredAwsAuthRefresh(): string | undefined { 280: const mergedSettings = getSettings_DEPRECATED() || {} 281: return mergedSettings.awsAuthRefresh 282: } 283: export function isAwsAuthRefreshFromProjectSettings(): boolean { 284: const awsAuthRefresh = getConfiguredAwsAuthRefresh() 285: if (!awsAuthRefresh) { 286: return false 287: } 288: const projectSettings = getSettingsForSource('projectSettings') 289: const localSettings = getSettingsForSource('localSettings') 290: return ( 291: projectSettings?.awsAuthRefresh === awsAuthRefresh || 292: localSettings?.awsAuthRefresh === awsAuthRefresh 293: ) 294: } 295: function getConfiguredAwsCredentialExport(): string | undefined { 296: const mergedSettings = getSettings_DEPRECATED() || {} 297: return mergedSettings.awsCredentialExport 298: } 299: export function isAwsCredentialExportFromProjectSettings(): boolean { 300: const awsCredentialExport = getConfiguredAwsCredentialExport() 301: if (!awsCredentialExport) { 302: return false 303: } 304: const projectSettings = getSettingsForSource('projectSettings') 305: const localSettings = getSettingsForSource('localSettings') 306: return ( 307: projectSettings?.awsCredentialExport === awsCredentialExport || 308: localSettings?.awsCredentialExport === awsCredentialExport 309: ) 310: } 311: export function calculateApiKeyHelperTTL(): number { 312: const envTtl = process.env.CLAUDE_CODE_API_KEY_HELPER_TTL_MS 313: if (envTtl) { 314: const parsed = parseInt(envTtl, 10) 315: if (!Number.isNaN(parsed) && parsed >= 0) { 316: return parsed 317: } 318: logForDebugging( 319: `Found CLAUDE_CODE_API_KEY_HELPER_TTL_MS env var, but it was not a valid number. Got ${envTtl}`, 320: { level: 'error' }, 321: ) 322: } 323: return DEFAULT_API_KEY_HELPER_TTL 324: } 325: let _apiKeyHelperCache: { value: string; timestamp: number } | null = null 326: let _apiKeyHelperInflight: { 327: promise: Promise<string | null> 328: startedAt: number | null 329: } | null = null 330: let _apiKeyHelperEpoch = 0 331: export function getApiKeyHelperElapsedMs(): number { 332: const startedAt = _apiKeyHelperInflight?.startedAt 333: return startedAt ? Date.now() - startedAt : 0 334: } 335: export async function getApiKeyFromApiKeyHelper( 336: isNonInteractiveSession: boolean, 337: ): Promise<string | null> { 338: if (!getConfiguredApiKeyHelper()) return null 339: const ttl = calculateApiKeyHelperTTL() 340: if (_apiKeyHelperCache) { 341: if (Date.now() - _apiKeyHelperCache.timestamp < ttl) { 342: return _apiKeyHelperCache.value 343: } 344: if (!_apiKeyHelperInflight) { 345: _apiKeyHelperInflight = { 346: promise: _runAndCache( 347: isNonInteractiveSession, 348: false, 349: _apiKeyHelperEpoch, 350: ), 351: startedAt: null, 352: } 353: } 354: return _apiKeyHelperCache.value 355: } 356: if (_apiKeyHelperInflight) return _apiKeyHelperInflight.promise 357: _apiKeyHelperInflight = { 358: promise: _runAndCache(isNonInteractiveSession, true, _apiKeyHelperEpoch), 359: startedAt: Date.now(), 360: } 361: return _apiKeyHelperInflight.promise 362: } 363: async function _runAndCache( 364: isNonInteractiveSession: boolean, 365: isCold: boolean, 366: epoch: number, 367: ): Promise<string | null> { 368: try { 369: const value = await _executeApiKeyHelper(isNonInteractiveSession) 370: if (epoch !== _apiKeyHelperEpoch) return value 371: if (value !== null) { 372: _apiKeyHelperCache = { value, timestamp: Date.now() } 373: } 374: return value 375: } catch (e) { 376: if (epoch !== _apiKeyHelperEpoch) return ' ' 377: const detail = e instanceof Error ? e.message : String(e) 378: console.error(chalk.red(`apiKeyHelper failed: ${detail}`)) 379: logForDebugging(`Error getting API key from apiKeyHelper: ${detail}`, { 380: level: 'error', 381: }) 382: if (!isCold && _apiKeyHelperCache && _apiKeyHelperCache.value !== ' ') { 383: _apiKeyHelperCache = { ..._apiKeyHelperCache, timestamp: Date.now() } 384: return _apiKeyHelperCache.value 385: } 386: _apiKeyHelperCache = { value: ' ', timestamp: Date.now() } 387: return ' ' 388: } finally { 389: if (epoch === _apiKeyHelperEpoch) { 390: _apiKeyHelperInflight = null 391: } 392: } 393: } 394: async function _executeApiKeyHelper( 395: isNonInteractiveSession: boolean, 396: ): Promise<string | null> { 397: const apiKeyHelper = getConfiguredApiKeyHelper() 398: if (!apiKeyHelper) { 399: return null 400: } 401: if (isApiKeyHelperFromProjectOrLocalSettings()) { 402: const hasTrust = checkHasTrustDialogAccepted() 403: if (!hasTrust && !isNonInteractiveSession) { 404: const error = new Error( 405: `Security: apiKeyHelper executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`, 406: ) 407: logAntError('apiKeyHelper invoked before trust check', error) 408: logEvent('tengu_apiKeyHelper_missing_trust11', {}) 409: return null 410: } 411: } 412: const result = await execa(apiKeyHelper, { 413: shell: true, 414: timeout: 10 * 60 * 1000, 415: reject: false, 416: }) 417: if (result.failed) { 418: const why = result.timedOut ? 'timed out' : `exited ${result.exitCode}` 419: const stderr = result.stderr?.trim() 420: throw new Error(stderr ? `${why}: ${stderr}` : why) 421: } 422: const stdout = result.stdout?.trim() 423: if (!stdout) { 424: throw new Error('did not return a value') 425: } 426: return stdout 427: } 428: export function getApiKeyFromApiKeyHelperCached(): string | null { 429: return _apiKeyHelperCache?.value ?? null 430: } 431: export function clearApiKeyHelperCache(): void { 432: _apiKeyHelperEpoch++ 433: _apiKeyHelperCache = null 434: _apiKeyHelperInflight = null 435: } 436: export function prefetchApiKeyFromApiKeyHelperIfSafe( 437: isNonInteractiveSession: boolean, 438: ): void { 439: if ( 440: isApiKeyHelperFromProjectOrLocalSettings() && 441: !checkHasTrustDialogAccepted() 442: ) { 443: return 444: } 445: void getApiKeyFromApiKeyHelper(isNonInteractiveSession) 446: } 447: const DEFAULT_AWS_STS_TTL = 60 * 60 * 1000 448: async function runAwsAuthRefresh(): Promise<boolean> { 449: const awsAuthRefresh = getConfiguredAwsAuthRefresh() 450: if (!awsAuthRefresh) { 451: return false 452: } 453: if (isAwsAuthRefreshFromProjectSettings()) { 454: const hasTrust = checkHasTrustDialogAccepted() 455: if (!hasTrust && !getIsNonInteractiveSession()) { 456: const error = new Error( 457: `Security: awsAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`, 458: ) 459: logAntError('awsAuthRefresh invoked before trust check', error) 460: logEvent('tengu_awsAuthRefresh_missing_trust', {}) 461: return false 462: } 463: } 464: try { 465: logForDebugging('Fetching AWS caller identity for AWS auth refresh command') 466: await checkStsCallerIdentity() 467: logForDebugging( 468: 'Fetched AWS caller identity, skipping AWS auth refresh command', 469: ) 470: return false 471: } catch { 472: return refreshAwsAuth(awsAuthRefresh) 473: } 474: } 475: const AWS_AUTH_REFRESH_TIMEOUT_MS = 3 * 60 * 1000 476: export function refreshAwsAuth(awsAuthRefresh: string): Promise<boolean> { 477: logForDebugging('Running AWS auth refresh command') 478: const authStatusManager = AwsAuthStatusManager.getInstance() 479: authStatusManager.startAuthentication() 480: return new Promise(resolve => { 481: const refreshProc = exec(awsAuthRefresh, { 482: timeout: AWS_AUTH_REFRESH_TIMEOUT_MS, 483: }) 484: refreshProc.stdout!.on('data', data => { 485: const output = data.toString().trim() 486: if (output) { 487: authStatusManager.addOutput(output) 488: logForDebugging(output, { level: 'debug' }) 489: } 490: }) 491: refreshProc.stderr!.on('data', data => { 492: const error = data.toString().trim() 493: if (error) { 494: authStatusManager.setError(error) 495: logForDebugging(error, { level: 'error' }) 496: } 497: }) 498: refreshProc.on('close', (code, signal) => { 499: if (code === 0) { 500: logForDebugging('AWS auth refresh completed successfully') 501: authStatusManager.endAuthentication(true) 502: void resolve(true) 503: } else { 504: const timedOut = signal === 'SIGTERM' 505: const message = timedOut 506: ? chalk.red( 507: 'AWS auth refresh timed out after 3 minutes. Run your auth command manually in a separate terminal.', 508: ) 509: : chalk.red( 510: 'Error running awsAuthRefresh (in settings or ~/.claude.json):', 511: ) 512: console.error(message) 513: authStatusManager.endAuthentication(false) 514: void resolve(false) 515: } 516: }) 517: }) 518: } 519: async function getAwsCredsFromCredentialExport(): Promise<{ 520: accessKeyId: string 521: secretAccessKey: string 522: sessionToken: string 523: } | null> { 524: const awsCredentialExport = getConfiguredAwsCredentialExport() 525: if (!awsCredentialExport) { 526: return null 527: } 528: if (isAwsCredentialExportFromProjectSettings()) { 529: const hasTrust = checkHasTrustDialogAccepted() 530: if (!hasTrust && !getIsNonInteractiveSession()) { 531: const error = new Error( 532: `Security: awsCredentialExport executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`, 533: ) 534: logAntError('awsCredentialExport invoked before trust check', error) 535: logEvent('tengu_awsCredentialExport_missing_trust', {}) 536: return null 537: } 538: } 539: try { 540: logForDebugging( 541: 'Fetching AWS caller identity for credential export command', 542: ) 543: await checkStsCallerIdentity() 544: logForDebugging( 545: 'Fetched AWS caller identity, skipping AWS credential export command', 546: ) 547: return null 548: } catch { 549: try { 550: logForDebugging('Running AWS credential export command') 551: const result = await execa(awsCredentialExport, { 552: shell: true, 553: reject: false, 554: }) 555: if (result.exitCode !== 0 || !result.stdout) { 556: throw new Error('awsCredentialExport did not return a valid value') 557: } 558: const awsOutput = jsonParse(result.stdout.trim()) 559: if (!isValidAwsStsOutput(awsOutput)) { 560: throw new Error( 561: 'awsCredentialExport did not return valid AWS STS output structure', 562: ) 563: } 564: logForDebugging('AWS credentials retrieved from awsCredentialExport') 565: return { 566: accessKeyId: awsOutput.Credentials.AccessKeyId, 567: secretAccessKey: awsOutput.Credentials.SecretAccessKey, 568: sessionToken: awsOutput.Credentials.SessionToken, 569: } 570: } catch (e) { 571: const message = chalk.red( 572: 'Error getting AWS credentials from awsCredentialExport (in settings or ~/.claude.json):', 573: ) 574: if (e instanceof Error) { 575: console.error(message, e.message) 576: } else { 577: console.error(message, e) 578: } 579: return null 580: } 581: } 582: } 583: export const refreshAndGetAwsCredentials = memoizeWithTTLAsync( 584: async (): Promise<{ 585: accessKeyId: string 586: secretAccessKey: string 587: sessionToken: string 588: } | null> => { 589: const refreshed = await runAwsAuthRefresh() 590: const credentials = await getAwsCredsFromCredentialExport() 591: if (refreshed || credentials) { 592: await clearAwsIniCache() 593: } 594: return credentials 595: }, 596: DEFAULT_AWS_STS_TTL, 597: ) 598: export function clearAwsCredentialsCache(): void { 599: refreshAndGetAwsCredentials.cache.clear() 600: } 601: function getConfiguredGcpAuthRefresh(): string | undefined { 602: const mergedSettings = getSettings_DEPRECATED() || {} 603: return mergedSettings.gcpAuthRefresh 604: } 605: export function isGcpAuthRefreshFromProjectSettings(): boolean { 606: const gcpAuthRefresh = getConfiguredGcpAuthRefresh() 607: if (!gcpAuthRefresh) { 608: return false 609: } 610: const projectSettings = getSettingsForSource('projectSettings') 611: const localSettings = getSettingsForSource('localSettings') 612: return ( 613: projectSettings?.gcpAuthRefresh === gcpAuthRefresh || 614: localSettings?.gcpAuthRefresh === gcpAuthRefresh 615: ) 616: } 617: const GCP_CREDENTIALS_CHECK_TIMEOUT_MS = 5_000 618: export async function checkGcpCredentialsValid(): Promise<boolean> { 619: try { 620: const { GoogleAuth } = await import('google-auth-library') 621: const auth = new GoogleAuth({ 622: scopes: ['https://www.googleapis.com/auth/cloud-platform'], 623: }) 624: const probe = (async () => { 625: const client = await auth.getClient() 626: await client.getAccessToken() 627: })() 628: const timeout = sleep(GCP_CREDENTIALS_CHECK_TIMEOUT_MS).then(() => { 629: throw new GcpCredentialsTimeoutError('GCP credentials check timed out') 630: }) 631: await Promise.race([probe, timeout]) 632: return true 633: } catch { 634: return false 635: } 636: } 637: const DEFAULT_GCP_CREDENTIAL_TTL = 60 * 60 * 1000 638: async function runGcpAuthRefresh(): Promise<boolean> { 639: const gcpAuthRefresh = getConfiguredGcpAuthRefresh() 640: if (!gcpAuthRefresh) { 641: return false 642: } 643: if (isGcpAuthRefreshFromProjectSettings()) { 644: const hasTrust = checkHasTrustDialogAccepted() 645: if (!hasTrust && !getIsNonInteractiveSession()) { 646: const error = new Error( 647: `Security: gcpAuthRefresh executed before workspace trust is confirmed. If you see this message, post in ${MACRO.FEEDBACK_CHANNEL}.`, 648: ) 649: logAntError('gcpAuthRefresh invoked before trust check', error) 650: logEvent('tengu_gcpAuthRefresh_missing_trust', {}) 651: return false 652: } 653: } 654: try { 655: logForDebugging('Checking GCP credentials validity for auth refresh') 656: const isValid = await checkGcpCredentialsValid() 657: if (isValid) { 658: logForDebugging( 659: 'GCP credentials are valid, skipping auth refresh command', 660: ) 661: return false 662: } 663: } catch { 664: } 665: return refreshGcpAuth(gcpAuthRefresh) 666: } 667: const GCP_AUTH_REFRESH_TIMEOUT_MS = 3 * 60 * 1000 668: export function refreshGcpAuth(gcpAuthRefresh: string): Promise<boolean> { 669: logForDebugging('Running GCP auth refresh command') 670: const authStatusManager = AwsAuthStatusManager.getInstance() 671: authStatusManager.startAuthentication() 672: return new Promise(resolve => { 673: const refreshProc = exec(gcpAuthRefresh, { 674: timeout: GCP_AUTH_REFRESH_TIMEOUT_MS, 675: }) 676: refreshProc.stdout!.on('data', data => { 677: const output = data.toString().trim() 678: if (output) { 679: authStatusManager.addOutput(output) 680: logForDebugging(output, { level: 'debug' }) 681: } 682: }) 683: refreshProc.stderr!.on('data', data => { 684: const error = data.toString().trim() 685: if (error) { 686: authStatusManager.setError(error) 687: logForDebugging(error, { level: 'error' }) 688: } 689: }) 690: refreshProc.on('close', (code, signal) => { 691: if (code === 0) { 692: logForDebugging('GCP auth refresh completed successfully') 693: authStatusManager.endAuthentication(true) 694: void resolve(true) 695: } else { 696: const timedOut = signal === 'SIGTERM' 697: const message = timedOut 698: ? chalk.red( 699: 'GCP auth refresh timed out after 3 minutes. Run your auth command manually in a separate terminal.', 700: ) 701: : chalk.red( 702: 'Error running gcpAuthRefresh (in settings or ~/.claude.json):', 703: ) 704: console.error(message) 705: authStatusManager.endAuthentication(false) 706: void resolve(false) 707: } 708: }) 709: }) 710: } 711: export const refreshGcpCredentialsIfNeeded = memoizeWithTTLAsync( 712: async (): Promise<boolean> => { 713: const refreshed = await runGcpAuthRefresh() 714: return refreshed 715: }, 716: DEFAULT_GCP_CREDENTIAL_TTL, 717: ) 718: export function clearGcpCredentialsCache(): void { 719: refreshGcpCredentialsIfNeeded.cache.clear() 720: } 721: export function prefetchGcpCredentialsIfSafe(): void { 722: const gcpAuthRefresh = getConfiguredGcpAuthRefresh() 723: if (!gcpAuthRefresh) { 724: return 725: } 726: if (isGcpAuthRefreshFromProjectSettings()) { 727: const hasTrust = checkHasTrustDialogAccepted() 728: if (!hasTrust && !getIsNonInteractiveSession()) { 729: return 730: } 731: } 732: void refreshGcpCredentialsIfNeeded() 733: } 734: export function prefetchAwsCredentialsAndBedRockInfoIfSafe(): void { 735: const awsAuthRefresh = getConfiguredAwsAuthRefresh() 736: const awsCredentialExport = getConfiguredAwsCredentialExport() 737: if (!awsAuthRefresh && !awsCredentialExport) { 738: return 739: } 740: if ( 741: isAwsAuthRefreshFromProjectSettings() || 742: isAwsCredentialExportFromProjectSettings() 743: ) { 744: const hasTrust = checkHasTrustDialogAccepted() 745: if (!hasTrust && !getIsNonInteractiveSession()) { 746: return 747: } 748: } 749: void refreshAndGetAwsCredentials() 750: getModelStrings() 751: } 752: export const getApiKeyFromConfigOrMacOSKeychain = memoize( 753: (): { key: string; source: ApiKeySource } | null => { 754: if (isBareMode()) return null 755: if (process.platform === 'darwin') { 756: const prefetch = getLegacyApiKeyPrefetchResult() 757: if (prefetch) { 758: if (prefetch.stdout) { 759: return { key: prefetch.stdout, source: '/login managed key' } 760: } 761: } else { 762: const storageServiceName = getMacOsKeychainStorageServiceName() 763: try { 764: const result = execSyncWithDefaults_DEPRECATED( 765: `security find-generic-password -a $USER -w -s "${storageServiceName}"`, 766: ) 767: if (result) { 768: return { key: result, source: '/login managed key' } 769: } 770: } catch (e) { 771: logError(e) 772: } 773: } 774: } 775: const config = getGlobalConfig() 776: if (!config.primaryApiKey) { 777: return null 778: } 779: return { key: config.primaryApiKey, source: '/login managed key' } 780: }, 781: ) 782: function isValidApiKey(apiKey: string): boolean { 783: return /^[a-zA-Z0-9-_]+$/.test(apiKey) 784: } 785: export async function saveApiKey(apiKey: string): Promise<void> { 786: if (!isValidApiKey(apiKey)) { 787: throw new Error( 788: 'Invalid API key format. API key must contain only alphanumeric characters, dashes, and underscores.', 789: ) 790: } 791: await maybeRemoveApiKeyFromMacOSKeychain() 792: let savedToKeychain = false 793: if (process.platform === 'darwin') { 794: try { 795: const storageServiceName = getMacOsKeychainStorageServiceName() 796: const username = getUsername() 797: const hexValue = Buffer.from(apiKey, 'utf-8').toString('hex') 798: const command = `add-generic-password -U -a "${username}" -s "${storageServiceName}" -X "${hexValue}"\n` 799: await execa('security', ['-i'], { 800: input: command, 801: reject: false, 802: }) 803: logEvent('tengu_api_key_saved_to_keychain', {}) 804: savedToKeychain = true 805: } catch (e) { 806: logError(e) 807: logEvent('tengu_api_key_keychain_error', { 808: error: errorMessage( 809: e, 810: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 811: }) 812: logEvent('tengu_api_key_saved_to_config', {}) 813: } 814: } else { 815: logEvent('tengu_api_key_saved_to_config', {}) 816: } 817: const normalizedKey = normalizeApiKeyForConfig(apiKey) 818: saveGlobalConfig(current => { 819: const approved = current.customApiKeyResponses?.approved ?? [] 820: return { 821: ...current, 822: primaryApiKey: savedToKeychain ? current.primaryApiKey : apiKey, 823: customApiKeyResponses: { 824: ...current.customApiKeyResponses, 825: approved: approved.includes(normalizedKey) 826: ? approved 827: : [...approved, normalizedKey], 828: rejected: current.customApiKeyResponses?.rejected ?? [], 829: }, 830: } 831: }) 832: getApiKeyFromConfigOrMacOSKeychain.cache.clear?.() 833: clearLegacyApiKeyPrefetch() 834: } 835: export function isCustomApiKeyApproved(apiKey: string): boolean { 836: const config = getGlobalConfig() 837: const normalizedKey = normalizeApiKeyForConfig(apiKey) 838: return ( 839: config.customApiKeyResponses?.approved?.includes(normalizedKey) ?? false 840: ) 841: } 842: export async function removeApiKey(): Promise<void> { 843: await maybeRemoveApiKeyFromMacOSKeychain() 844: saveGlobalConfig(current => ({ 845: ...current, 846: primaryApiKey: undefined, 847: })) 848: getApiKeyFromConfigOrMacOSKeychain.cache.clear?.() 849: clearLegacyApiKeyPrefetch() 850: } 851: async function maybeRemoveApiKeyFromMacOSKeychain(): Promise<void> { 852: try { 853: await maybeRemoveApiKeyFromMacOSKeychainThrows() 854: } catch (e) { 855: logError(e) 856: } 857: } 858: export function saveOAuthTokensIfNeeded(tokens: OAuthTokens): { 859: success: boolean 860: warning?: string 861: } { 862: if (!shouldUseClaudeAIAuth(tokens.scopes)) { 863: logEvent('tengu_oauth_tokens_not_claude_ai', {}) 864: return { success: true } 865: } 866: if (!tokens.refreshToken || !tokens.expiresAt) { 867: logEvent('tengu_oauth_tokens_inference_only', {}) 868: return { success: true } 869: } 870: const secureStorage = getSecureStorage() 871: const storageBackend = 872: secureStorage.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 873: try { 874: const storageData = secureStorage.read() || {} 875: const existingOauth = storageData.claudeAiOauth 876: storageData.claudeAiOauth = { 877: accessToken: tokens.accessToken, 878: refreshToken: tokens.refreshToken, 879: expiresAt: tokens.expiresAt, 880: scopes: tokens.scopes, 881: subscriptionType: 882: tokens.subscriptionType ?? existingOauth?.subscriptionType ?? null, 883: rateLimitTier: 884: tokens.rateLimitTier ?? existingOauth?.rateLimitTier ?? null, 885: } 886: const updateStatus = secureStorage.update(storageData) 887: if (updateStatus.success) { 888: logEvent('tengu_oauth_tokens_saved', { storageBackend }) 889: } else { 890: logEvent('tengu_oauth_tokens_save_failed', { storageBackend }) 891: } 892: getClaudeAIOAuthTokens.cache?.clear?.() 893: clearBetasCaches() 894: clearToolSchemaCache() 895: return updateStatus 896: } catch (error) { 897: logError(error) 898: logEvent('tengu_oauth_tokens_save_exception', { 899: storageBackend, 900: error: errorMessage( 901: error, 902: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 903: }) 904: return { success: false, warning: 'Failed to save OAuth tokens' } 905: } 906: } 907: export const getClaudeAIOAuthTokens = memoize((): OAuthTokens | null => { 908: if (isBareMode()) return null 909: if (process.env.CLAUDE_CODE_OAUTH_TOKEN) { 910: return { 911: accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN, 912: refreshToken: null, 913: expiresAt: null, 914: scopes: ['user:inference'], 915: subscriptionType: null, 916: rateLimitTier: null, 917: } 918: } 919: const oauthTokenFromFd = getOAuthTokenFromFileDescriptor() 920: if (oauthTokenFromFd) { 921: return { 922: accessToken: oauthTokenFromFd, 923: refreshToken: null, 924: expiresAt: null, 925: scopes: ['user:inference'], 926: subscriptionType: null, 927: rateLimitTier: null, 928: } 929: } 930: try { 931: const secureStorage = getSecureStorage() 932: const storageData = secureStorage.read() 933: const oauthData = storageData?.claudeAiOauth 934: if (!oauthData?.accessToken) { 935: return null 936: } 937: return oauthData 938: } catch (error) { 939: logError(error) 940: return null 941: } 942: }) 943: export function clearOAuthTokenCache(): void { 944: getClaudeAIOAuthTokens.cache?.clear?.() 945: clearKeychainCache() 946: } 947: let lastCredentialsMtimeMs = 0 948: async function invalidateOAuthCacheIfDiskChanged(): Promise<void> { 949: try { 950: const { mtimeMs } = await stat( 951: join(getClaudeConfigHomeDir(), '.credentials.json'), 952: ) 953: if (mtimeMs !== lastCredentialsMtimeMs) { 954: lastCredentialsMtimeMs = mtimeMs 955: clearOAuthTokenCache() 956: } 957: } catch { 958: getClaudeAIOAuthTokens.cache?.clear?.() 959: } 960: } 961: const pending401Handlers = new Map<string, Promise<boolean>>() 962: export function handleOAuth401Error( 963: failedAccessToken: string, 964: ): Promise<boolean> { 965: const pending = pending401Handlers.get(failedAccessToken) 966: if (pending) return pending 967: const promise = handleOAuth401ErrorImpl(failedAccessToken).finally(() => { 968: pending401Handlers.delete(failedAccessToken) 969: }) 970: pending401Handlers.set(failedAccessToken, promise) 971: return promise 972: } 973: async function handleOAuth401ErrorImpl( 974: failedAccessToken: string, 975: ): Promise<boolean> { 976: clearOAuthTokenCache() 977: const currentTokens = await getClaudeAIOAuthTokensAsync() 978: if (!currentTokens?.refreshToken) { 979: return false 980: } 981: if (currentTokens.accessToken !== failedAccessToken) { 982: logEvent('tengu_oauth_401_recovered_from_keychain', {}) 983: return true 984: } 985: return checkAndRefreshOAuthTokenIfNeeded(0, true) 986: } 987: export async function getClaudeAIOAuthTokensAsync(): Promise<OAuthTokens | null> { 988: if (isBareMode()) return null 989: if ( 990: process.env.CLAUDE_CODE_OAUTH_TOKEN || 991: getOAuthTokenFromFileDescriptor() 992: ) { 993: return getClaudeAIOAuthTokens() 994: } 995: try { 996: const secureStorage = getSecureStorage() 997: const storageData = await secureStorage.readAsync() 998: const oauthData = storageData?.claudeAiOauth 999: if (!oauthData?.accessToken) { 1000: return null 1001: } 1002: return oauthData 1003: } catch (error) { 1004: logError(error) 1005: return null 1006: } 1007: } 1008: let pendingRefreshCheck: Promise<boolean> | null = null 1009: export function checkAndRefreshOAuthTokenIfNeeded( 1010: retryCount = 0, 1011: force = false, 1012: ): Promise<boolean> { 1013: if (retryCount === 0 && !force) { 1014: if (pendingRefreshCheck) { 1015: return pendingRefreshCheck 1016: } 1017: const promise = checkAndRefreshOAuthTokenIfNeededImpl(retryCount, force) 1018: pendingRefreshCheck = promise.finally(() => { 1019: pendingRefreshCheck = null 1020: }) 1021: return pendingRefreshCheck 1022: } 1023: return checkAndRefreshOAuthTokenIfNeededImpl(retryCount, force) 1024: } 1025: async function checkAndRefreshOAuthTokenIfNeededImpl( 1026: retryCount: number, 1027: force: boolean, 1028: ): Promise<boolean> { 1029: const MAX_RETRIES = 5 1030: await invalidateOAuthCacheIfDiskChanged() 1031: const tokens = getClaudeAIOAuthTokens() 1032: if (!force) { 1033: if (!tokens?.refreshToken || !isOAuthTokenExpired(tokens.expiresAt)) { 1034: return false 1035: } 1036: } 1037: if (!tokens?.refreshToken) { 1038: return false 1039: } 1040: if (!shouldUseClaudeAIAuth(tokens.scopes)) { 1041: return false 1042: } 1043: getClaudeAIOAuthTokens.cache?.clear?.() 1044: clearKeychainCache() 1045: const freshTokens = await getClaudeAIOAuthTokensAsync() 1046: if ( 1047: !freshTokens?.refreshToken || 1048: !isOAuthTokenExpired(freshTokens.expiresAt) 1049: ) { 1050: return false 1051: } 1052: const claudeDir = getClaudeConfigHomeDir() 1053: await mkdir(claudeDir, { recursive: true }) 1054: let release 1055: try { 1056: logEvent('tengu_oauth_token_refresh_lock_acquiring', {}) 1057: release = await lockfile.lock(claudeDir) 1058: logEvent('tengu_oauth_token_refresh_lock_acquired', {}) 1059: } catch (err) { 1060: if ((err as { code?: string }).code === 'ELOCKED') { 1061: if (retryCount < MAX_RETRIES) { 1062: logEvent('tengu_oauth_token_refresh_lock_retry', { 1063: retryCount: retryCount + 1, 1064: }) 1065: await sleep(1000 + Math.random() * 1000) 1066: return checkAndRefreshOAuthTokenIfNeededImpl(retryCount + 1, force) 1067: } 1068: logEvent('tengu_oauth_token_refresh_lock_retry_limit_reached', { 1069: maxRetries: MAX_RETRIES, 1070: }) 1071: return false 1072: } 1073: logError(err) 1074: logEvent('tengu_oauth_token_refresh_lock_error', { 1075: error: errorMessage( 1076: err, 1077: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1078: }) 1079: return false 1080: } 1081: try { 1082: getClaudeAIOAuthTokens.cache?.clear?.() 1083: clearKeychainCache() 1084: const lockedTokens = await getClaudeAIOAuthTokensAsync() 1085: if ( 1086: !lockedTokens?.refreshToken || 1087: !isOAuthTokenExpired(lockedTokens.expiresAt) 1088: ) { 1089: logEvent('tengu_oauth_token_refresh_race_resolved', {}) 1090: return false 1091: } 1092: logEvent('tengu_oauth_token_refresh_starting', {}) 1093: const refreshedTokens = await refreshOAuthToken(lockedTokens.refreshToken, { 1094: scopes: shouldUseClaudeAIAuth(lockedTokens.scopes) 1095: ? undefined 1096: : lockedTokens.scopes, 1097: }) 1098: saveOAuthTokensIfNeeded(refreshedTokens) 1099: getClaudeAIOAuthTokens.cache?.clear?.() 1100: clearKeychainCache() 1101: return true 1102: } catch (error) { 1103: logError(error) 1104: getClaudeAIOAuthTokens.cache?.clear?.() 1105: clearKeychainCache() 1106: const currentTokens = await getClaudeAIOAuthTokensAsync() 1107: if (currentTokens && !isOAuthTokenExpired(currentTokens.expiresAt)) { 1108: logEvent('tengu_oauth_token_refresh_race_recovered', {}) 1109: return true 1110: } 1111: return false 1112: } finally { 1113: logEvent('tengu_oauth_token_refresh_lock_releasing', {}) 1114: await release() 1115: logEvent('tengu_oauth_token_refresh_lock_released', {}) 1116: } 1117: } 1118: export function isClaudeAISubscriber(): boolean { 1119: if (!isAnthropicAuthEnabled()) { 1120: return false 1121: } 1122: return shouldUseClaudeAIAuth(getClaudeAIOAuthTokens()?.scopes) 1123: } 1124: export function hasProfileScope(): boolean { 1125: return ( 1126: getClaudeAIOAuthTokens()?.scopes?.includes(CLAUDE_AI_PROFILE_SCOPE) ?? false 1127: ) 1128: } 1129: export function is1PApiCustomer(): boolean { 1130: if ( 1131: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 1132: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 1133: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) 1134: ) { 1135: return false 1136: } 1137: if (isClaudeAISubscriber()) { 1138: return false 1139: } 1140: return true 1141: } 1142: export function getOauthAccountInfo(): AccountInfo | undefined { 1143: return isAnthropicAuthEnabled() ? getGlobalConfig().oauthAccount : undefined 1144: } 1145: export function isOverageProvisioningAllowed(): boolean { 1146: const accountInfo = getOauthAccountInfo() 1147: const billingType = accountInfo?.billingType 1148: if (!isClaudeAISubscriber() || !billingType) { 1149: return false 1150: } 1151: if ( 1152: billingType !== 'stripe_subscription' && 1153: billingType !== 'stripe_subscription_contracted' && 1154: billingType !== 'apple_subscription' && 1155: billingType !== 'google_play_subscription' 1156: ) { 1157: return false 1158: } 1159: return true 1160: } 1161: export function hasOpusAccess(): boolean { 1162: const subscriptionType = getSubscriptionType() 1163: return ( 1164: subscriptionType === 'max' || 1165: subscriptionType === 'enterprise' || 1166: subscriptionType === 'team' || 1167: subscriptionType === 'pro' || 1168: subscriptionType === null 1169: ) 1170: } 1171: export function getSubscriptionType(): SubscriptionType | null { 1172: if (shouldUseMockSubscription()) { 1173: return getMockSubscriptionType() 1174: } 1175: if (!isAnthropicAuthEnabled()) { 1176: return null 1177: } 1178: const oauthTokens = getClaudeAIOAuthTokens() 1179: if (!oauthTokens) { 1180: return null 1181: } 1182: return oauthTokens.subscriptionType ?? null 1183: } 1184: export function isMaxSubscriber(): boolean { 1185: return getSubscriptionType() === 'max' 1186: } 1187: export function isTeamSubscriber(): boolean { 1188: return getSubscriptionType() === 'team' 1189: } 1190: export function isTeamPremiumSubscriber(): boolean { 1191: return ( 1192: getSubscriptionType() === 'team' && 1193: getRateLimitTier() === 'default_claude_max_5x' 1194: ) 1195: } 1196: export function isEnterpriseSubscriber(): boolean { 1197: return getSubscriptionType() === 'enterprise' 1198: } 1199: export function isProSubscriber(): boolean { 1200: return getSubscriptionType() === 'pro' 1201: } 1202: export function getRateLimitTier(): string | null { 1203: if (!isAnthropicAuthEnabled()) { 1204: return null 1205: } 1206: const oauthTokens = getClaudeAIOAuthTokens() 1207: if (!oauthTokens) { 1208: return null 1209: } 1210: return oauthTokens.rateLimitTier ?? null 1211: } 1212: export function getSubscriptionName(): string { 1213: const subscriptionType = getSubscriptionType() 1214: switch (subscriptionType) { 1215: case 'enterprise': 1216: return 'Claude Enterprise' 1217: case 'team': 1218: return 'Claude Team' 1219: case 'max': 1220: return 'Claude Max' 1221: case 'pro': 1222: return 'Claude Pro' 1223: default: 1224: return 'Claude API' 1225: } 1226: } 1227: export function isUsing3PServices(): boolean { 1228: return !!( 1229: isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) || 1230: isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) || 1231: isEnvTruthy(process.env.CLAUDE_CODE_USE_FOUNDRY) 1232: ) 1233: } 1234: function getConfiguredOtelHeadersHelper(): string | undefined { 1235: const mergedSettings = getSettings_DEPRECATED() || {} 1236: return mergedSettings.otelHeadersHelper 1237: } 1238: export function isOtelHeadersHelperFromProjectOrLocalSettings(): boolean { 1239: const otelHeadersHelper = getConfiguredOtelHeadersHelper() 1240: if (!otelHeadersHelper) { 1241: return false 1242: } 1243: const projectSettings = getSettingsForSource('projectSettings') 1244: const localSettings = getSettingsForSource('localSettings') 1245: return ( 1246: projectSettings?.otelHeadersHelper === otelHeadersHelper || 1247: localSettings?.otelHeadersHelper === otelHeadersHelper 1248: ) 1249: } 1250: let cachedOtelHeaders: Record<string, string> | null = null 1251: let cachedOtelHeadersTimestamp = 0 1252: const DEFAULT_OTEL_HEADERS_DEBOUNCE_MS = 29 * 60 * 1000 1253: export function getOtelHeadersFromHelper(): Record<string, string> { 1254: const otelHeadersHelper = getConfiguredOtelHeadersHelper() 1255: if (!otelHeadersHelper) { 1256: return {} 1257: } 1258: const debounceMs = parseInt( 1259: process.env.CLAUDE_CODE_OTEL_HEADERS_HELPER_DEBOUNCE_MS || 1260: DEFAULT_OTEL_HEADERS_DEBOUNCE_MS.toString(), 1261: ) 1262: if ( 1263: cachedOtelHeaders && 1264: Date.now() - cachedOtelHeadersTimestamp < debounceMs 1265: ) { 1266: return cachedOtelHeaders 1267: } 1268: if (isOtelHeadersHelperFromProjectOrLocalSettings()) { 1269: const hasTrust = checkHasTrustDialogAccepted() 1270: if (!hasTrust) { 1271: return {} 1272: } 1273: } 1274: try { 1275: const result = execSyncWithDefaults_DEPRECATED(otelHeadersHelper, { 1276: timeout: 30000, 1277: }) 1278: ?.toString() 1279: .trim() 1280: if (!result) { 1281: throw new Error('otelHeadersHelper did not return a valid value') 1282: } 1283: const headers = jsonParse(result) 1284: if ( 1285: typeof headers !== 'object' || 1286: headers === null || 1287: Array.isArray(headers) 1288: ) { 1289: throw new Error( 1290: 'otelHeadersHelper must return a JSON object with string key-value pairs', 1291: ) 1292: } 1293: for (const [key, value] of Object.entries(headers)) { 1294: if (typeof value !== 'string') { 1295: throw new Error( 1296: `otelHeadersHelper returned non-string value for key "${key}": ${typeof value}`, 1297: ) 1298: } 1299: } 1300: cachedOtelHeaders = headers as Record<string, string> 1301: cachedOtelHeadersTimestamp = Date.now() 1302: return cachedOtelHeaders 1303: } catch (error) { 1304: logError( 1305: new Error( 1306: `Error getting OpenTelemetry headers from otelHeadersHelper (in settings): ${errorMessage(error)}`, 1307: ), 1308: ) 1309: throw error 1310: } 1311: } 1312: function isConsumerPlan(plan: SubscriptionType): plan is 'max' | 'pro' { 1313: return plan === 'max' || plan === 'pro' 1314: } 1315: export function isConsumerSubscriber(): boolean { 1316: const subscriptionType = getSubscriptionType() 1317: return ( 1318: isClaudeAISubscriber() && 1319: subscriptionType !== null && 1320: isConsumerPlan(subscriptionType) 1321: ) 1322: } 1323: export type UserAccountInfo = { 1324: subscription?: string 1325: tokenSource?: string 1326: apiKeySource?: ApiKeySource 1327: organization?: string 1328: email?: string 1329: } 1330: export function getAccountInformation() { 1331: const apiProvider = getAPIProvider() 1332: if (apiProvider !== 'firstParty') { 1333: return undefined 1334: } 1335: const { source: authTokenSource } = getAuthTokenSource() 1336: const accountInfo: UserAccountInfo = {} 1337: if ( 1338: authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN' || 1339: authTokenSource === 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR' 1340: ) { 1341: accountInfo.tokenSource = authTokenSource 1342: } else if (isClaudeAISubscriber()) { 1343: accountInfo.subscription = getSubscriptionName() 1344: } else { 1345: accountInfo.tokenSource = authTokenSource 1346: } 1347: const { key: apiKey, source: apiKeySource } = getAnthropicApiKeyWithSource() 1348: if (apiKey) { 1349: accountInfo.apiKeySource = apiKeySource 1350: } 1351: if ( 1352: authTokenSource === 'claude.ai' || 1353: apiKeySource === '/login managed key' 1354: ) { 1355: const orgName = getOauthAccountInfo()?.organizationName 1356: if (orgName) { 1357: accountInfo.organization = orgName 1358: } 1359: } 1360: const email = getOauthAccountInfo()?.emailAddress 1361: if ( 1362: (authTokenSource === 'claude.ai' || 1363: apiKeySource === '/login managed key') && 1364: email 1365: ) { 1366: accountInfo.email = email 1367: } 1368: return accountInfo 1369: } 1370: export type OrgValidationResult = 1371: | { valid: true } 1372: | { valid: false; message: string } 1373: export async function validateForceLoginOrg(): Promise<OrgValidationResult> { 1374: if (process.env.ANTHROPIC_UNIX_SOCKET) { 1375: return { valid: true } 1376: } 1377: if (!isAnthropicAuthEnabled()) { 1378: return { valid: true } 1379: } 1380: const requiredOrgUuid = 1381: getSettingsForSource('policySettings')?.forceLoginOrgUUID 1382: if (!requiredOrgUuid) { 1383: return { valid: true } 1384: } 1385: await checkAndRefreshOAuthTokenIfNeeded() 1386: const tokens = getClaudeAIOAuthTokens() 1387: if (!tokens) { 1388: return { valid: true } 1389: } 1390: const { source } = getAuthTokenSource() 1391: const isEnvVarToken = 1392: source === 'CLAUDE_CODE_OAUTH_TOKEN' || 1393: source === 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR' 1394: const profile = await getOauthProfileFromOauthToken(tokens.accessToken) 1395: if (!profile) { 1396: return { 1397: valid: false, 1398: message: 1399: `Unable to verify organization for the current authentication token.\n` + 1400: `This machine requires organization ${requiredOrgUuid} but the profile could not be fetched.\n` + 1401: `This may be a network error, or the token may lack the user:profile scope required for\n` + 1402: `verification (tokens from 'claude setup-token' do not include this scope).\n` + 1403: `Try again, or obtain a full-scope token via 'claude auth login'.`, 1404: } 1405: } 1406: const tokenOrgUuid = profile.organization.uuid 1407: if (tokenOrgUuid === requiredOrgUuid) { 1408: return { valid: true } 1409: } 1410: if (isEnvVarToken) { 1411: const envVarName = 1412: source === 'CLAUDE_CODE_OAUTH_TOKEN' 1413: ? 'CLAUDE_CODE_OAUTH_TOKEN' 1414: : 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR' 1415: return { 1416: valid: false, 1417: message: 1418: `The ${envVarName} environment variable provides a token for a\n` + 1419: `different organization than required by this machine's managed settings.\n\n` + 1420: `Required organization: ${requiredOrgUuid}\n` + 1421: `Token organization: ${tokenOrgUuid}\n\n` + 1422: `Remove the environment variable or obtain a token for the correct organization.`, 1423: } 1424: } 1425: return { 1426: valid: false, 1427: message: 1428: `Your authentication token belongs to organization ${tokenOrgUuid},\n` + 1429: `but this machine requires organization ${requiredOrgUuid}.\n\n` + 1430: `Please log in with the correct organization: claude auth login`, 1431: } 1432: } 1433: class GcpCredentialsTimeoutError extends Error {}

File: src/utils/authFileDescriptor.ts

typescript 1: import { mkdirSync, writeFileSync } from 'fs' 2: import { 3: getApiKeyFromFd, 4: getOauthTokenFromFd, 5: setApiKeyFromFd, 6: setOauthTokenFromFd, 7: } from '../bootstrap/state.js' 8: import { logForDebugging } from './debug.js' 9: import { isEnvTruthy } from './envUtils.js' 10: import { errorMessage, isENOENT } from './errors.js' 11: import { getFsImplementation } from './fsOperations.js' 12: const CCR_TOKEN_DIR = '/home/claude/.claude/remote' 13: export const CCR_OAUTH_TOKEN_PATH = `${CCR_TOKEN_DIR}/.oauth_token` 14: export const CCR_API_KEY_PATH = `${CCR_TOKEN_DIR}/.api_key` 15: export const CCR_SESSION_INGRESS_TOKEN_PATH = `${CCR_TOKEN_DIR}/.session_ingress_token` 16: export function maybePersistTokenForSubprocesses( 17: path: string, 18: token: string, 19: tokenName: string, 20: ): void { 21: if (!isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { 22: return 23: } 24: try { 25: mkdirSync(CCR_TOKEN_DIR, { recursive: true, mode: 0o700 }) 26: writeFileSync(path, token, { encoding: 'utf8', mode: 0o600 }) 27: logForDebugging(`Persisted ${tokenName} to ${path} for subprocess access`) 28: } catch (error) { 29: logForDebugging( 30: `Failed to persist ${tokenName} to disk (non-fatal): ${errorMessage(error)}`, 31: { level: 'error' }, 32: ) 33: } 34: } 35: export function readTokenFromWellKnownFile( 36: path: string, 37: tokenName: string, 38: ): string | null { 39: try { 40: const fsOps = getFsImplementation() 41: const token = fsOps.readFileSync(path, { encoding: 'utf8' }).trim() 42: if (!token) { 43: return null 44: } 45: logForDebugging(`Read ${tokenName} from well-known file ${path}`) 46: return token 47: } catch (error) { 48: if (!isENOENT(error)) { 49: logForDebugging( 50: `Failed to read ${tokenName} from ${path}: ${errorMessage(error)}`, 51: { level: 'debug' }, 52: ) 53: } 54: return null 55: } 56: } 57: function getCredentialFromFd({ 58: envVar, 59: wellKnownPath, 60: label, 61: getCached, 62: setCached, 63: }: { 64: envVar: string 65: wellKnownPath: string 66: label: string 67: getCached: () => string | null | undefined 68: setCached: (value: string | null) => void 69: }): string | null { 70: const cached = getCached() 71: if (cached !== undefined) { 72: return cached 73: } 74: const fdEnv = process.env[envVar] 75: if (!fdEnv) { 76: const fromFile = readTokenFromWellKnownFile(wellKnownPath, label) 77: setCached(fromFile) 78: return fromFile 79: } 80: const fd = parseInt(fdEnv, 10) 81: if (Number.isNaN(fd)) { 82: logForDebugging( 83: `${envVar} must be a valid file descriptor number, got: ${fdEnv}`, 84: { level: 'error' }, 85: ) 86: setCached(null) 87: return null 88: } 89: try { 90: const fsOps = getFsImplementation() 91: const fdPath = 92: process.platform === 'darwin' || process.platform === 'freebsd' 93: ? `/dev/fd/${fd}` 94: : `/proc/self/fd/${fd}` 95: const token = fsOps.readFileSync(fdPath, { encoding: 'utf8' }).trim() 96: if (!token) { 97: logForDebugging(`File descriptor contained empty ${label}`, { 98: level: 'error', 99: }) 100: setCached(null) 101: return null 102: } 103: logForDebugging(`Successfully read ${label} from file descriptor ${fd}`) 104: setCached(token) 105: maybePersistTokenForSubprocesses(wellKnownPath, token, label) 106: return token 107: } catch (error) { 108: logForDebugging( 109: `Failed to read ${label} from file descriptor ${fd}: ${errorMessage(error)}`, 110: { level: 'error' }, 111: ) 112: const fromFile = readTokenFromWellKnownFile(wellKnownPath, label) 113: setCached(fromFile) 114: return fromFile 115: } 116: } 117: export function getOAuthTokenFromFileDescriptor(): string | null { 118: return getCredentialFromFd({ 119: envVar: 'CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR', 120: wellKnownPath: CCR_OAUTH_TOKEN_PATH, 121: label: 'OAuth token', 122: getCached: getOauthTokenFromFd, 123: setCached: setOauthTokenFromFd, 124: }) 125: } 126: export function getApiKeyFromFileDescriptor(): string | null { 127: return getCredentialFromFd({ 128: envVar: 'CLAUDE_CODE_API_KEY_FILE_DESCRIPTOR', 129: wellKnownPath: CCR_API_KEY_PATH, 130: label: 'API key', 131: getCached: getApiKeyFromFd, 132: setCached: setApiKeyFromFd, 133: }) 134: }

File: src/utils/authPortable.ts

typescript 1: import { execa } from 'execa' 2: import { getMacOsKeychainStorageServiceName } from 'src/utils/secureStorage/macOsKeychainHelpers.js' 3: export async function maybeRemoveApiKeyFromMacOSKeychainThrows(): Promise<void> { 4: if (process.platform === 'darwin') { 5: const storageServiceName = getMacOsKeychainStorageServiceName() 6: const result = await execa( 7: `security delete-generic-password -a $USER -s "${storageServiceName}"`, 8: { shell: true, reject: false }, 9: ) 10: if (result.exitCode !== 0) { 11: throw new Error('Failed to delete keychain entry') 12: } 13: } 14: } 15: export function normalizeApiKeyForConfig(apiKey: string): string { 16: return apiKey.slice(-20) 17: }

File: src/utils/autoModeDenials.ts

typescript 1: import { feature } from 'bun:bundle' 2: export type AutoModeDenial = { 3: toolName: string 4: display: string 5: reason: string 6: timestamp: number 7: } 8: let DENIALS: readonly AutoModeDenial[] = [] 9: const MAX_DENIALS = 20 10: export function recordAutoModeDenial(denial: AutoModeDenial): void { 11: if (!feature('TRANSCRIPT_CLASSIFIER')) return 12: DENIALS = [denial, ...DENIALS.slice(0, MAX_DENIALS - 1)] 13: } 14: export function getAutoModeDenials(): readonly AutoModeDenial[] { 15: return DENIALS 16: }

File: src/utils/autoRunIssue.tsx

typescript 1: import { c as _c } from "react/compiler-runtime"; 2: import * as React from 'react'; 3: import { useEffect, useRef } from 'react'; 4: import { KeyboardShortcutHint } from '../components/design-system/KeyboardShortcutHint.js'; 5: import { Box, Text } from '../ink.js'; 6: import { useKeybinding } from '../keybindings/useKeybinding.js'; 7: type Props = { 8: onRun: () => void; 9: onCancel: () => void; 10: reason: string; 11: }; 12: export function AutoRunIssueNotification(t0) { 13: const $ = _c(8); 14: const { 15: onRun, 16: onCancel, 17: reason 18: } = t0; 19: const hasRunRef = useRef(false); 20: let t1; 21: if ($[0] === Symbol.for("react.memo_cache_sentinel")) { 22: t1 = { 23: context: "Confirmation" 24: }; 25: $[0] = t1; 26: } else { 27: t1 = $[0]; 28: } 29: useKeybinding("confirm:no", onCancel, t1); 30: let t2; 31: let t3; 32: if ($[1] !== onRun) { 33: t2 = () => { 34: if (!hasRunRef.current) { 35: hasRunRef.current = true; 36: onRun(); 37: } 38: }; 39: t3 = [onRun]; 40: $[1] = onRun; 41: $[2] = t2; 42: $[3] = t3; 43: } else { 44: t2 = $[2]; 45: t3 = $[3]; 46: } 47: useEffect(t2, t3); 48: let t4; 49: if ($[4] === Symbol.for("react.memo_cache_sentinel")) { 50: t4 = <Box><Text bold={true}>Running feedback capture...</Text></Box>; 51: $[4] = t4; 52: } else { 53: t4 = $[4]; 54: } 55: let t5; 56: if ($[5] === Symbol.for("react.memo_cache_sentinel")) { 57: t5 = <Box><Text dimColor={true}>Press <KeyboardShortcutHint shortcut="Esc" action="cancel" /> anytime</Text></Box>; 58: $[5] = t5; 59: } else { 60: t5 = $[5]; 61: } 62: let t6; 63: if ($[6] !== reason) { 64: t6 = <Box flexDirection="column" marginTop={1}>{t4}{t5}<Box><Text dimColor={true}>Reason: {reason}</Text></Box></Box>; 65: $[6] = reason; 66: $[7] = t6; 67: } else { 68: t6 = $[7]; 69: } 70: return t6; 71: } 72: export type AutoRunIssueReason = 'feedback_survey_bad' | 'feedback_survey_good'; 73: export function shouldAutoRunIssue(reason: AutoRunIssueReason): boolean { 74: if ("external" !== 'ant') { 75: return false; 76: } 77: switch (reason) { 78: case 'feedback_survey_bad': 79: return false; 80: case 'feedback_survey_good': 81: return false; 82: default: 83: return false; 84: } 85: } 86: export function getAutoRunCommand(reason: AutoRunIssueReason): string { 87: if ("external" === 'ant' && reason === 'feedback_survey_good') { 88: return '/good-claude'; 89: } 90: return '/issue'; 91: } 92: export function getAutoRunIssueReasonText(reason: AutoRunIssueReason): string { 93: switch (reason) { 94: case 'feedback_survey_bad': 95: return 'You responded "Bad" to the feedback survey'; 96: case 'feedback_survey_good': 97: return 'You responded "Good" to the feedback survey'; 98: default: 99: return 'Unknown reason'; 100: } 101: }

File: src/utils/autoUpdater.ts

typescript 1: import axios from 'axios' 2: import { constants as fsConstants } from 'fs' 3: import { access, writeFile } from 'fs/promises' 4: import { homedir } from 'os' 5: import { join } from 'path' 6: import { getDynamicConfig_BLOCKS_ON_INIT } from 'src/services/analytics/growthbook.js' 7: import { 8: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9: logEvent, 10: } from 'src/services/analytics/index.js' 11: import { type ReleaseChannel, saveGlobalConfig } from './config.js' 12: import { logForDebugging } from './debug.js' 13: import { env } from './env.js' 14: import { getClaudeConfigHomeDir } from './envUtils.js' 15: import { ClaudeError, getErrnoCode, isENOENT } from './errors.js' 16: import { execFileNoThrowWithCwd } from './execFileNoThrow.js' 17: import { getFsImplementation } from './fsOperations.js' 18: import { gracefulShutdownSync } from './gracefulShutdown.js' 19: import { logError } from './log.js' 20: import { gte, lt } from './semver.js' 21: import { getInitialSettings } from './settings/settings.js' 22: import { 23: filterClaudeAliases, 24: getShellConfigPaths, 25: readFileLines, 26: writeFileLines, 27: } from './shellConfig.js' 28: import { jsonParse } from './slowOperations.js' 29: const GCS_BUCKET_URL = 30: 'https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases' 31: class AutoUpdaterError extends ClaudeError {} 32: export type InstallStatus = 33: | 'success' 34: | 'no_permissions' 35: | 'install_failed' 36: | 'in_progress' 37: export type AutoUpdaterResult = { 38: version: string | null 39: status: InstallStatus 40: notifications?: string[] 41: } 42: export type MaxVersionConfig = { 43: external?: string 44: ant?: string 45: external_message?: string 46: ant_message?: string 47: } 48: export async function assertMinVersion(): Promise<void> { 49: if (process.env.NODE_ENV === 'test') { 50: return 51: } 52: try { 53: const versionConfig = await getDynamicConfig_BLOCKS_ON_INIT<{ 54: minVersion: string 55: }>('tengu_version_config', { minVersion: '0.0.0' }) 56: if ( 57: versionConfig.minVersion && 58: lt(MACRO.VERSION, versionConfig.minVersion) 59: ) { 60: console.error(` 61: It looks like your version of Claude Code (${MACRO.VERSION}) needs an update. 62: A newer version (${versionConfig.minVersion} or higher) is required to continue. 63: To update, please run: 64: claude update 65: This will ensure you have access to the latest features and improvements. 66: `) 67: gracefulShutdownSync(1) 68: } 69: } catch (error) { 70: logError(error as Error) 71: } 72: } 73: export async function getMaxVersion(): Promise<string | undefined> { 74: const config = await getMaxVersionConfig() 75: if (process.env.USER_TYPE === 'ant') { 76: return config.ant || undefined 77: } 78: return config.external || undefined 79: } 80: export async function getMaxVersionMessage(): Promise<string | undefined> { 81: const config = await getMaxVersionConfig() 82: if (process.env.USER_TYPE === 'ant') { 83: return config.ant_message || undefined 84: } 85: return config.external_message || undefined 86: } 87: async function getMaxVersionConfig(): Promise<MaxVersionConfig> { 88: try { 89: return await getDynamicConfig_BLOCKS_ON_INIT<MaxVersionConfig>( 90: 'tengu_max_version_config', 91: {}, 92: ) 93: } catch (error) { 94: logError(error as Error) 95: return {} 96: } 97: } 98: export function shouldSkipVersion(targetVersion: string): boolean { 99: const settings = getInitialSettings() 100: const minimumVersion = settings?.minimumVersion 101: if (!minimumVersion) { 102: return false 103: } 104: const shouldSkip = !gte(targetVersion, minimumVersion) 105: if (shouldSkip) { 106: logForDebugging( 107: `Skipping update to ${targetVersion} - below minimumVersion ${minimumVersion}`, 108: ) 109: } 110: return shouldSkip 111: } 112: const LOCK_TIMEOUT_MS = 5 * 60 * 1000 113: export function getLockFilePath(): string { 114: return join(getClaudeConfigHomeDir(), '.update.lock') 115: } 116: async function acquireLock(): Promise<boolean> { 117: const fs = getFsImplementation() 118: const lockPath = getLockFilePath() 119: try { 120: const stats = await fs.stat(lockPath) 121: const age = Date.now() - stats.mtimeMs 122: if (age < LOCK_TIMEOUT_MS) { 123: return false 124: } 125: try { 126: const recheck = await fs.stat(lockPath) 127: if (Date.now() - recheck.mtimeMs < LOCK_TIMEOUT_MS) { 128: return false 129: } 130: await fs.unlink(lockPath) 131: } catch (err) { 132: if (!isENOENT(err)) { 133: logError(err as Error) 134: return false 135: } 136: } 137: } catch (err) { 138: if (!isENOENT(err)) { 139: logError(err as Error) 140: return false 141: } 142: } 143: try { 144: await writeFile(lockPath, `${process.pid}`, { 145: encoding: 'utf8', 146: flag: 'wx', 147: }) 148: return true 149: } catch (err) { 150: const code = getErrnoCode(err) 151: if (code === 'EEXIST') { 152: return false 153: } 154: if (code === 'ENOENT') { 155: try { 156: await fs.mkdir(getClaudeConfigHomeDir()) 157: await writeFile(lockPath, `${process.pid}`, { 158: encoding: 'utf8', 159: flag: 'wx', 160: }) 161: return true 162: } catch (mkdirErr) { 163: if (getErrnoCode(mkdirErr) === 'EEXIST') { 164: return false 165: } 166: logError(mkdirErr as Error) 167: return false 168: } 169: } 170: logError(err as Error) 171: return false 172: } 173: } 174: async function releaseLock(): Promise<void> { 175: const fs = getFsImplementation() 176: const lockPath = getLockFilePath() 177: try { 178: const lockData = await fs.readFile(lockPath, { encoding: 'utf8' }) 179: if (lockData === `${process.pid}`) { 180: await fs.unlink(lockPath) 181: } 182: } catch (err) { 183: if (isENOENT(err)) { 184: return 185: } 186: logError(err as Error) 187: } 188: } 189: async function getInstallationPrefix(): Promise<string | null> { 190: const isBun = env.isRunningWithBun() 191: let prefixResult = null 192: if (isBun) { 193: prefixResult = await execFileNoThrowWithCwd('bun', ['pm', 'bin', '-g'], { 194: cwd: homedir(), 195: }) 196: } else { 197: prefixResult = await execFileNoThrowWithCwd( 198: 'npm', 199: ['-g', 'config', 'get', 'prefix'], 200: { cwd: homedir() }, 201: ) 202: } 203: if (prefixResult.code !== 0) { 204: logError(new Error(`Failed to check ${isBun ? 'bun' : 'npm'} permissions`)) 205: return null 206: } 207: return prefixResult.stdout.trim() 208: } 209: export async function checkGlobalInstallPermissions(): Promise<{ 210: hasPermissions: boolean 211: npmPrefix: string | null 212: }> { 213: try { 214: const prefix = await getInstallationPrefix() 215: if (!prefix) { 216: return { hasPermissions: false, npmPrefix: null } 217: } 218: try { 219: await access(prefix, fsConstants.W_OK) 220: return { hasPermissions: true, npmPrefix: prefix } 221: } catch { 222: logError( 223: new AutoUpdaterError( 224: 'Insufficient permissions for global npm install.', 225: ), 226: ) 227: return { hasPermissions: false, npmPrefix: prefix } 228: } 229: } catch (error) { 230: logError(error as Error) 231: return { hasPermissions: false, npmPrefix: null } 232: } 233: } 234: export async function getLatestVersion( 235: channel: ReleaseChannel, 236: ): Promise<string | null> { 237: const npmTag = channel === 'stable' ? 'stable' : 'latest' 238: const result = await execFileNoThrowWithCwd( 239: 'npm', 240: ['view', `${MACRO.PACKAGE_URL}@${npmTag}`, 'version', '--prefer-online'], 241: { abortSignal: AbortSignal.timeout(5000), cwd: homedir() }, 242: ) 243: if (result.code !== 0) { 244: logForDebugging(`npm view failed with code ${result.code}`) 245: if (result.stderr) { 246: logForDebugging(`npm stderr: ${result.stderr.trim()}`) 247: } else { 248: logForDebugging('npm stderr: (empty)') 249: } 250: if (result.stdout) { 251: logForDebugging(`npm stdout: ${result.stdout.trim()}`) 252: } 253: return null 254: } 255: return result.stdout.trim() 256: } 257: export type NpmDistTags = { 258: latest: string | null 259: stable: string | null 260: } 261: export async function getNpmDistTags(): Promise<NpmDistTags> { 262: const result = await execFileNoThrowWithCwd( 263: 'npm', 264: ['view', MACRO.PACKAGE_URL, 'dist-tags', '--json', '--prefer-online'], 265: { abortSignal: AbortSignal.timeout(5000), cwd: homedir() }, 266: ) 267: if (result.code !== 0) { 268: logForDebugging(`npm view dist-tags failed with code ${result.code}`) 269: return { latest: null, stable: null } 270: } 271: try { 272: const parsed = jsonParse(result.stdout.trim()) as Record<string, unknown> 273: return { 274: latest: typeof parsed.latest === 'string' ? parsed.latest : null, 275: stable: typeof parsed.stable === 'string' ? parsed.stable : null, 276: } 277: } catch (error) { 278: logForDebugging(`Failed to parse dist-tags: ${error}`) 279: return { latest: null, stable: null } 280: } 281: } 282: export async function getLatestVersionFromGcs( 283: channel: ReleaseChannel, 284: ): Promise<string | null> { 285: try { 286: const response = await axios.get(`${GCS_BUCKET_URL}/${channel}`, { 287: timeout: 5000, 288: responseType: 'text', 289: }) 290: return response.data.trim() 291: } catch (error) { 292: logForDebugging(`Failed to fetch ${channel} from GCS: ${error}`) 293: return null 294: } 295: } 296: export async function getGcsDistTags(): Promise<NpmDistTags> { 297: const [latest, stable] = await Promise.all([ 298: getLatestVersionFromGcs('latest'), 299: getLatestVersionFromGcs('stable'), 300: ]) 301: return { latest, stable } 302: } 303: export async function getVersionHistory(limit: number): Promise<string[]> { 304: if (process.env.USER_TYPE !== 'ant') { 305: return [] 306: } 307: const packageUrl = MACRO.NATIVE_PACKAGE_URL ?? MACRO.PACKAGE_URL 308: const result = await execFileNoThrowWithCwd( 309: 'npm', 310: ['view', packageUrl, 'versions', '--json', '--prefer-online'], 311: { abortSignal: AbortSignal.timeout(30000), cwd: homedir() }, 312: ) 313: if (result.code !== 0) { 314: logForDebugging(`npm view versions failed with code ${result.code}`) 315: if (result.stderr) { 316: logForDebugging(`npm stderr: ${result.stderr.trim()}`) 317: } 318: return [] 319: } 320: try { 321: const versions = jsonParse(result.stdout.trim()) as string[] 322: return versions.slice(-limit).reverse() 323: } catch (error) { 324: logForDebugging(`Failed to parse version history: ${error}`) 325: return [] 326: } 327: } 328: export async function installGlobalPackage( 329: specificVersion?: string | null, 330: ): Promise<InstallStatus> { 331: if (!(await acquireLock())) { 332: logError( 333: new AutoUpdaterError('Another process is currently installing an update'), 334: ) 335: logEvent('tengu_auto_updater_lock_contention', { 336: pid: process.pid, 337: currentVersion: 338: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 339: }) 340: return 'in_progress' 341: } 342: try { 343: await removeClaudeAliasesFromShellConfigs() 344: if (!env.isRunningWithBun() && env.isNpmFromWindowsPath()) { 345: logError(new Error('Windows NPM detected in WSL environment')) 346: logEvent('tengu_auto_updater_windows_npm_in_wsl', { 347: currentVersion: 348: MACRO.VERSION as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 349: }) 350: console.error(` 351: Error: Windows NPM detected in WSL 352: You're running Claude Code in WSL but using the Windows NPM installation from /mnt/c/. 353: This configuration is not supported for updates. 354: To fix this issue: 355: 1. Install Node.js within your Linux distribution: e.g. sudo apt install nodejs npm 356: 2. Make sure Linux NPM is in your PATH before the Windows version 357: 3. Try updating again with 'claude update' 358: `) 359: return 'install_failed' 360: } 361: const { hasPermissions } = await checkGlobalInstallPermissions() 362: if (!hasPermissions) { 363: return 'no_permissions' 364: } 365: const packageSpec = specificVersion 366: ? `${MACRO.PACKAGE_URL}@${specificVersion}` 367: : MACRO.PACKAGE_URL 368: const packageManager = env.isRunningWithBun() ? 'bun' : 'npm' 369: const installResult = await execFileNoThrowWithCwd( 370: packageManager, 371: ['install', '-g', packageSpec], 372: { cwd: homedir() }, 373: ) 374: if (installResult.code !== 0) { 375: const error = new AutoUpdaterError( 376: `Failed to install new version of claude: ${installResult.stdout} ${installResult.stderr}`, 377: ) 378: logError(error) 379: return 'install_failed' 380: } 381: saveGlobalConfig(current => ({ 382: ...current, 383: installMethod: 'global', 384: })) 385: return 'success' 386: } finally { 387: await releaseLock() 388: } 389: } 390: async function removeClaudeAliasesFromShellConfigs(): Promise<void> { 391: const configMap = getShellConfigPaths() 392: for (const [, configFile] of Object.entries(configMap)) { 393: try { 394: const lines = await readFileLines(configFile) 395: if (!lines) continue 396: const { filtered, hadAlias } = filterClaudeAliases(lines) 397: if (hadAlias) { 398: await writeFileLines(configFile, filtered) 399: logForDebugging(`Removed claude alias from ${configFile}`) 400: } 401: } catch (error) { 402: logForDebugging(`Failed to remove alias from ${configFile}: ${error}`, { 403: level: 'error', 404: }) 405: } 406: } 407: }

File: src/utils/aws.ts

typescript 1: import { logForDebugging } from './debug.js' 2: export type AwsCredentials = { 3: AccessKeyId: string 4: SecretAccessKey: string 5: SessionToken: string 6: Expiration?: string 7: } 8: export type AwsStsOutput = { 9: Credentials: AwsCredentials 10: } 11: type AwsError = { 12: name: string 13: } 14: export function isAwsCredentialsProviderError(err: unknown) { 15: return (err as AwsError | undefined)?.name === 'CredentialsProviderError' 16: } 17: export function isValidAwsStsOutput(obj: unknown): obj is AwsStsOutput { 18: if (!obj || typeof obj !== 'object') { 19: return false 20: } 21: const output = obj as Record<string, unknown> 22: if (!output.Credentials || typeof output.Credentials !== 'object') { 23: return false 24: } 25: const credentials = output.Credentials as Record<string, unknown> 26: return ( 27: typeof credentials.AccessKeyId === 'string' && 28: typeof credentials.SecretAccessKey === 'string' && 29: typeof credentials.SessionToken === 'string' && 30: credentials.AccessKeyId.length > 0 && 31: credentials.SecretAccessKey.length > 0 && 32: credentials.SessionToken.length > 0 33: ) 34: } 35: export async function checkStsCallerIdentity(): Promise<void> { 36: const { STSClient, GetCallerIdentityCommand } = await import( 37: '@aws-sdk/client-sts' 38: ) 39: await new STSClient().send(new GetCallerIdentityCommand({})) 40: } 41: export async function clearAwsIniCache(): Promise<void> { 42: try { 43: logForDebugging('Clearing AWS credential provider cache') 44: const { fromIni } = await import('@aws-sdk/credential-providers') 45: const iniProvider = fromIni({ ignoreCache: true }) 46: await iniProvider() 47: logForDebugging('AWS credential provider cache refreshed') 48: } catch (_error) { 49: logForDebugging( 50: 'Failed to clear AWS credential cache (this is expected if no credentials are configured)', 51: ) 52: } 53: }

File: src/utils/awsAuthStatusManager.ts

typescript 1: import { createSignal } from './signal.js' 2: export type AwsAuthStatus = { 3: isAuthenticating: boolean 4: output: string[] 5: error?: string 6: } 7: export class AwsAuthStatusManager { 8: private static instance: AwsAuthStatusManager | null = null 9: private status: AwsAuthStatus = { 10: isAuthenticating: false, 11: output: [], 12: } 13: private changed = createSignal<[status: AwsAuthStatus]>() 14: static getInstance(): AwsAuthStatusManager { 15: if (!AwsAuthStatusManager.instance) { 16: AwsAuthStatusManager.instance = new AwsAuthStatusManager() 17: } 18: return AwsAuthStatusManager.instance 19: } 20: getStatus(): AwsAuthStatus { 21: return { 22: ...this.status, 23: output: [...this.status.output], 24: } 25: } 26: startAuthentication(): void { 27: this.status = { 28: isAuthenticating: true, 29: output: [], 30: } 31: this.changed.emit(this.getStatus()) 32: } 33: addOutput(line: string): void { 34: this.status.output.push(line) 35: this.changed.emit(this.getStatus()) 36: } 37: setError(error: string): void { 38: this.status.error = error 39: this.changed.emit(this.getStatus()) 40: } 41: endAuthentication(success: boolean): void { 42: if (success) { 43: this.status = { 44: isAuthenticating: false, 45: output: [], 46: } 47: } else { 48: this.status.isAuthenticating = false 49: } 50: this.changed.emit(this.getStatus()) 51: } 52: subscribe = this.changed.subscribe 53: static reset(): void { 54: if (AwsAuthStatusManager.instance) { 55: AwsAuthStatusManager.instance.changed.clear() 56: AwsAuthStatusManager.instance = null 57: } 58: } 59: }

File: src/utils/backgroundHousekeeping.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { initAutoDream } from '../services/autoDream/autoDream.js' 3: import { initMagicDocs } from '../services/MagicDocs/magicDocs.js' 4: import { initSkillImprovement } from './hooks/skillImprovement.js' 5: const extractMemoriesModule = feature('EXTRACT_MEMORIES') 6: ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) 7: : null 8: const registerProtocolModule = feature('LODESTONE') 9: ? (require('./deepLink/registerProtocol.js') as typeof import('./deepLink/registerProtocol.js')) 10: : null 11: import { getIsInteractive, getLastInteractionTime } from '../bootstrap/state.js' 12: import { 13: cleanupNpmCacheForAnthropicPackages, 14: cleanupOldMessageFilesInBackground, 15: cleanupOldVersionsThrottled, 16: } from './cleanup.js' 17: import { cleanupOldVersions } from './nativeInstaller/index.js' 18: import { autoUpdateMarketplacesAndPluginsInBackground } from './plugins/pluginAutoupdate.js' 19: const RECURRING_CLEANUP_INTERVAL_MS = 24 * 60 * 60 * 1000 20: const DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION = 10 * 60 * 1000 21: export function startBackgroundHousekeeping(): void { 22: void initMagicDocs() 23: void initSkillImprovement() 24: if (feature('EXTRACT_MEMORIES')) { 25: extractMemoriesModule!.initExtractMemories() 26: } 27: initAutoDream() 28: void autoUpdateMarketplacesAndPluginsInBackground() 29: if (feature('LODESTONE') && getIsInteractive()) { 30: void registerProtocolModule!.ensureDeepLinkProtocolRegistered() 31: } 32: let needsCleanup = true 33: async function runVerySlowOps(): Promise<void> { 34: if ( 35: getIsInteractive() && 36: getLastInteractionTime() > Date.now() - 1000 * 60 37: ) { 38: setTimeout( 39: runVerySlowOps, 40: DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION, 41: ).unref() 42: return 43: } 44: if (needsCleanup) { 45: needsCleanup = false 46: await cleanupOldMessageFilesInBackground() 47: } 48: if ( 49: getIsInteractive() && 50: getLastInteractionTime() > Date.now() - 1000 * 60 51: ) { 52: setTimeout( 53: runVerySlowOps, 54: DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION, 55: ).unref() 56: return 57: } 58: await cleanupOldVersions() 59: } 60: setTimeout( 61: runVerySlowOps, 62: DELAY_VERY_SLOW_OPERATIONS_THAT_HAPPEN_EVERY_SESSION, 63: ).unref() 64: if (process.env.USER_TYPE === 'ant') { 65: const interval = setInterval(() => { 66: void cleanupNpmCacheForAnthropicPackages() 67: void cleanupOldVersionsThrottled() 68: }, RECURRING_CLEANUP_INTERVAL_MS) 69: interval.unref() 70: } 71: }

File: src/utils/betas.ts

typescript 1: import { feature } from 'bun:bundle' 2: import memoize from 'lodash-es/memoize.js' 3: import { 4: checkStatsigFeatureGate_CACHED_MAY_BE_STALE, 5: getFeatureValue_CACHED_MAY_BE_STALE, 6: } from 'src/services/analytics/growthbook.js' 7: import { getIsNonInteractiveSession, getSdkBetas } from '../bootstrap/state.js' 8: import { 9: BEDROCK_EXTRA_PARAMS_HEADERS, 10: CLAUDE_CODE_20250219_BETA_HEADER, 11: CLI_INTERNAL_BETA_HEADER, 12: CONTEXT_1M_BETA_HEADER, 13: CONTEXT_MANAGEMENT_BETA_HEADER, 14: INTERLEAVED_THINKING_BETA_HEADER, 15: PROMPT_CACHING_SCOPE_BETA_HEADER, 16: REDACT_THINKING_BETA_HEADER, 17: STRUCTURED_OUTPUTS_BETA_HEADER, 18: SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER, 19: TOKEN_EFFICIENT_TOOLS_BETA_HEADER, 20: TOOL_SEARCH_BETA_HEADER_1P, 21: TOOL_SEARCH_BETA_HEADER_3P, 22: WEB_SEARCH_BETA_HEADER, 23: } from '../constants/betas.js' 24: import { OAUTH_BETA_HEADER } from '../constants/oauth.js' 25: import { isClaudeAISubscriber } from './auth.js' 26: import { has1mContext } from './context.js' 27: import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' 28: import { getCanonicalName } from './model/model.js' 29: import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' 30: import { getAPIProvider } from './model/providers.js' 31: import { getInitialSettings } from './settings/settings.js' 32: const ALLOWED_SDK_BETAS = [CONTEXT_1M_BETA_HEADER] 33: function partitionBetasByAllowlist(betas: string[]): { 34: allowed: string[] 35: disallowed: string[] 36: } { 37: const allowed: string[] = [] 38: const disallowed: string[] = [] 39: for (const beta of betas) { 40: if (ALLOWED_SDK_BETAS.includes(beta)) { 41: allowed.push(beta) 42: } else { 43: disallowed.push(beta) 44: } 45: } 46: return { allowed, disallowed } 47: } 48: export function filterAllowedSdkBetas( 49: sdkBetas: string[] | undefined, 50: ): string[] | undefined { 51: if (!sdkBetas || sdkBetas.length === 0) { 52: return undefined 53: } 54: if (isClaudeAISubscriber()) { 55: console.warn( 56: 'Warning: Custom betas are only available for API key users. Ignoring provided betas.', 57: ) 58: return undefined 59: } 60: const { allowed, disallowed } = partitionBetasByAllowlist(sdkBetas) 61: for (const beta of disallowed) { 62: console.warn( 63: `Warning: Beta header '${beta}' is not allowed. Only the following betas are supported: ${ALLOWED_SDK_BETAS.join(', ')}`, 64: ) 65: } 66: return allowed.length > 0 ? allowed : undefined 67: } 68: export function modelSupportsISP(model: string): boolean { 69: const supported3P = get3PModelCapabilityOverride( 70: model, 71: 'interleaved_thinking', 72: ) 73: if (supported3P !== undefined) { 74: return supported3P 75: } 76: const canonical = getCanonicalName(model) 77: const provider = getAPIProvider() 78: if (provider === 'foundry') { 79: return true 80: } 81: if (provider === 'firstParty') { 82: return !canonical.includes('claude-3-') 83: } 84: return ( 85: canonical.includes('claude-opus-4') || canonical.includes('claude-sonnet-4') 86: ) 87: } 88: function vertexModelSupportsWebSearch(model: string): boolean { 89: const canonical = getCanonicalName(model) 90: return ( 91: canonical.includes('claude-opus-4') || 92: canonical.includes('claude-sonnet-4') || 93: canonical.includes('claude-haiku-4') 94: ) 95: } 96: export function modelSupportsContextManagement(model: string): boolean { 97: const canonical = getCanonicalName(model) 98: const provider = getAPIProvider() 99: if (provider === 'foundry') { 100: return true 101: } 102: if (provider === 'firstParty') { 103: return !canonical.includes('claude-3-') 104: } 105: return ( 106: canonical.includes('claude-opus-4') || 107: canonical.includes('claude-sonnet-4') || 108: canonical.includes('claude-haiku-4') 109: ) 110: } 111: export function modelSupportsStructuredOutputs(model: string): boolean { 112: const canonical = getCanonicalName(model) 113: const provider = getAPIProvider() 114: if (provider !== 'firstParty' && provider !== 'foundry') { 115: return false 116: } 117: return ( 118: canonical.includes('claude-sonnet-4-6') || 119: canonical.includes('claude-sonnet-4-5') || 120: canonical.includes('claude-opus-4-1') || 121: canonical.includes('claude-opus-4-5') || 122: canonical.includes('claude-opus-4-6') || 123: canonical.includes('claude-haiku-4-5') 124: ) 125: } 126: export function modelSupportsAutoMode(model: string): boolean { 127: if (feature('TRANSCRIPT_CLASSIFIER')) { 128: const m = getCanonicalName(model) 129: if (process.env.USER_TYPE !== 'ant' && getAPIProvider() !== 'firstParty') { 130: return false 131: } 132: const config = getFeatureValue_CACHED_MAY_BE_STALE<{ 133: allowModels?: string[] 134: }>('tengu_auto_mode_config', {}) 135: const rawLower = model.toLowerCase() 136: if ( 137: config?.allowModels?.some( 138: am => am.toLowerCase() === rawLower || am.toLowerCase() === m, 139: ) 140: ) { 141: return true 142: } 143: if (process.env.USER_TYPE === 'ant') { 144: if (m.includes('claude-3-')) return false 145: if (/claude-(opus|sonnet|haiku)-4(?!-[6-9])/.test(m)) return false 146: return true 147: } 148: return /^claude-(opus|sonnet)-4-6/.test(m) 149: } 150: return false 151: } 152: export function getToolSearchBetaHeader(): string { 153: const provider = getAPIProvider() 154: if (provider === 'vertex' || provider === 'bedrock') { 155: return TOOL_SEARCH_BETA_HEADER_3P 156: } 157: return TOOL_SEARCH_BETA_HEADER_1P 158: } 159: export function shouldIncludeFirstPartyOnlyBetas(): boolean { 160: return ( 161: (getAPIProvider() === 'firstParty' || getAPIProvider() === 'foundry') && 162: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) 163: ) 164: } 165: export function shouldUseGlobalCacheScope(): boolean { 166: return ( 167: getAPIProvider() === 'firstParty' && 168: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS) 169: ) 170: } 171: export const getAllModelBetas = memoize((model: string): string[] => { 172: const betaHeaders = [] 173: const isHaiku = getCanonicalName(model).includes('haiku') 174: const provider = getAPIProvider() 175: const includeFirstPartyOnlyBetas = shouldIncludeFirstPartyOnlyBetas() 176: if (!isHaiku) { 177: betaHeaders.push(CLAUDE_CODE_20250219_BETA_HEADER) 178: if ( 179: process.env.USER_TYPE === 'ant' && 180: process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' 181: ) { 182: if (CLI_INTERNAL_BETA_HEADER) { 183: betaHeaders.push(CLI_INTERNAL_BETA_HEADER) 184: } 185: } 186: } 187: if (isClaudeAISubscriber()) { 188: betaHeaders.push(OAUTH_BETA_HEADER) 189: } 190: if (has1mContext(model)) { 191: betaHeaders.push(CONTEXT_1M_BETA_HEADER) 192: } 193: if ( 194: !isEnvTruthy(process.env.DISABLE_INTERLEAVED_THINKING) && 195: modelSupportsISP(model) 196: ) { 197: betaHeaders.push(INTERLEAVED_THINKING_BETA_HEADER) 198: } 199: if ( 200: includeFirstPartyOnlyBetas && 201: modelSupportsISP(model) && 202: !getIsNonInteractiveSession() && 203: getInitialSettings().showThinkingSummaries !== true 204: ) { 205: betaHeaders.push(REDACT_THINKING_BETA_HEADER) 206: } 207: if ( 208: SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER && 209: process.env.USER_TYPE === 'ant' && 210: includeFirstPartyOnlyBetas && 211: !isEnvDefinedFalsy(process.env.USE_CONNECTOR_TEXT_SUMMARIZATION) && 212: (isEnvTruthy(process.env.USE_CONNECTOR_TEXT_SUMMARIZATION) || 213: getFeatureValue_CACHED_MAY_BE_STALE('tengu_slate_prism', false)) 214: ) { 215: betaHeaders.push(SUMMARIZE_CONNECTOR_TEXT_BETA_HEADER) 216: } 217: const antOptedIntoToolClearing = 218: isEnvTruthy(process.env.USE_API_CONTEXT_MANAGEMENT) && 219: process.env.USER_TYPE === 'ant' 220: const thinkingPreservationEnabled = modelSupportsContextManagement(model) 221: if ( 222: shouldIncludeFirstPartyOnlyBetas() && 223: (antOptedIntoToolClearing || thinkingPreservationEnabled) 224: ) { 225: betaHeaders.push(CONTEXT_MANAGEMENT_BETA_HEADER) 226: } 227: const strictToolsEnabled = 228: checkStatsigFeatureGate_CACHED_MAY_BE_STALE('tengu_tool_pear') 229: const tokenEfficientToolsEnabled = 230: !strictToolsEnabled && 231: getFeatureValue_CACHED_MAY_BE_STALE('tengu_amber_json_tools', false) 232: if ( 233: includeFirstPartyOnlyBetas && 234: modelSupportsStructuredOutputs(model) && 235: strictToolsEnabled 236: ) { 237: betaHeaders.push(STRUCTURED_OUTPUTS_BETA_HEADER) 238: } 239: if ( 240: process.env.USER_TYPE === 'ant' && 241: includeFirstPartyOnlyBetas && 242: tokenEfficientToolsEnabled 243: ) { 244: betaHeaders.push(TOKEN_EFFICIENT_TOOLS_BETA_HEADER) 245: } 246: if (provider === 'vertex' && vertexModelSupportsWebSearch(model)) { 247: betaHeaders.push(WEB_SEARCH_BETA_HEADER) 248: } 249: if (provider === 'foundry') { 250: betaHeaders.push(WEB_SEARCH_BETA_HEADER) 251: } 252: if (includeFirstPartyOnlyBetas) { 253: betaHeaders.push(PROMPT_CACHING_SCOPE_BETA_HEADER) 254: } 255: if (process.env.ANTHROPIC_BETAS) { 256: betaHeaders.push( 257: ...process.env.ANTHROPIC_BETAS.split(',') 258: .map(_ => _.trim()) 259: .filter(Boolean), 260: ) 261: } 262: return betaHeaders 263: }) 264: export const getModelBetas = memoize((model: string): string[] => { 265: const modelBetas = getAllModelBetas(model) 266: if (getAPIProvider() === 'bedrock') { 267: return modelBetas.filter(b => !BEDROCK_EXTRA_PARAMS_HEADERS.has(b)) 268: } 269: return modelBetas 270: }) 271: export const getBedrockExtraBodyParamsBetas = memoize( 272: (model: string): string[] => { 273: const modelBetas = getAllModelBetas(model) 274: return modelBetas.filter(b => BEDROCK_EXTRA_PARAMS_HEADERS.has(b)) 275: }, 276: ) 277: export function getMergedBetas( 278: model: string, 279: options?: { isAgenticQuery?: boolean }, 280: ): string[] { 281: const baseBetas = [...getModelBetas(model)] 282: if (options?.isAgenticQuery) { 283: if (!baseBetas.includes(CLAUDE_CODE_20250219_BETA_HEADER)) { 284: baseBetas.push(CLAUDE_CODE_20250219_BETA_HEADER) 285: } 286: if ( 287: process.env.USER_TYPE === 'ant' && 288: process.env.CLAUDE_CODE_ENTRYPOINT === 'cli' && 289: CLI_INTERNAL_BETA_HEADER && 290: !baseBetas.includes(CLI_INTERNAL_BETA_HEADER) 291: ) { 292: baseBetas.push(CLI_INTERNAL_BETA_HEADER) 293: } 294: } 295: const sdkBetas = getSdkBetas() 296: if (!sdkBetas || sdkBetas.length === 0) { 297: return baseBetas 298: } 299: return [...baseBetas, ...sdkBetas.filter(b => !baseBetas.includes(b))] 300: } 301: export function clearBetasCaches(): void { 302: getAllModelBetas.cache?.clear?.() 303: getModelBetas.cache?.clear?.() 304: getBedrockExtraBodyParamsBetas.cache?.clear?.() 305: }

File: src/utils/billing.ts

typescript 1: import { 2: getAnthropicApiKey, 3: getAuthTokenSource, 4: getSubscriptionType, 5: isClaudeAISubscriber, 6: } from './auth.js' 7: import { getGlobalConfig } from './config.js' 8: import { isEnvTruthy } from './envUtils.js' 9: export function hasConsoleBillingAccess(): boolean { 10: if (isEnvTruthy(process.env.DISABLE_COST_WARNINGS)) { 11: return false 12: } 13: const isSubscriber = isClaudeAISubscriber() 14: if (isSubscriber) return false 15: const authSource = getAuthTokenSource() 16: const hasApiKey = getAnthropicApiKey() !== null 17: if (!authSource.hasToken && !hasApiKey) { 18: return false 19: } 20: const config = getGlobalConfig() 21: const orgRole = config.oauthAccount?.organizationRole 22: const workspaceRole = config.oauthAccount?.workspaceRole 23: if (!orgRole || !workspaceRole) { 24: return false 25: } 26: return ( 27: ['admin', 'billing'].includes(orgRole) || 28: ['workspace_admin', 'workspace_billing'].includes(workspaceRole) 29: ) 30: } 31: let mockBillingAccessOverride: boolean | null = null 32: export function setMockBillingAccessOverride(value: boolean | null): void { 33: mockBillingAccessOverride = value 34: } 35: export function hasClaudeAiBillingAccess(): boolean { 36: if (mockBillingAccessOverride !== null) { 37: return mockBillingAccessOverride 38: } 39: if (!isClaudeAISubscriber()) { 40: return false 41: } 42: const subscriptionType = getSubscriptionType() 43: if (subscriptionType === 'max' || subscriptionType === 'pro') { 44: return true 45: } 46: const config = getGlobalConfig() 47: const orgRole = config.oauthAccount?.organizationRole 48: return ( 49: !!orgRole && 50: ['admin', 'billing', 'owner', 'primary_owner'].includes(orgRole) 51: ) 52: }

File: src/utils/binaryCheck.ts

typescript 1: import { logForDebugging } from './debug.js' 2: import { which } from './which.js' 3: const binaryCache = new Map<string, boolean>() 4: export async function isBinaryInstalled(command: string): Promise<boolean> { 5: if (!command || !command.trim()) { 6: logForDebugging('[binaryCheck] Empty command provided, returning false') 7: return false 8: } 9: const trimmedCommand = command.trim() 10: const cached = binaryCache.get(trimmedCommand) 11: if (cached !== undefined) { 12: logForDebugging( 13: `[binaryCheck] Cache hit for '${trimmedCommand}': ${cached}`, 14: ) 15: return cached 16: } 17: let exists = false 18: if (await which(trimmedCommand).catch(() => null)) { 19: exists = true 20: } 21: binaryCache.set(trimmedCommand, exists) 22: logForDebugging( 23: `[binaryCheck] Binary '${trimmedCommand}' ${exists ? 'found' : 'not found'}`, 24: ) 25: return exists 26: } 27: export function clearBinaryCache(): void { 28: binaryCache.clear() 29: }

File: src/utils/browser.ts

typescript 1: import { execFileNoThrow } from './execFileNoThrow.js' 2: function validateUrl(url: string): void { 3: let parsedUrl: URL 4: try { 5: parsedUrl = new URL(url) 6: } catch (_error) { 7: throw new Error(`Invalid URL format: ${url}`) 8: } 9: if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') { 10: throw new Error( 11: `Invalid URL protocol: must use http:// or https://, got ${parsedUrl.protocol}`, 12: ) 13: } 14: } 15: export async function openPath(path: string): Promise<boolean> { 16: try { 17: const platform = process.platform 18: if (platform === 'win32') { 19: const { code } = await execFileNoThrow('explorer', [path]) 20: return code === 0 21: } 22: const command = platform === 'darwin' ? 'open' : 'xdg-open' 23: const { code } = await execFileNoThrow(command, [path]) 24: return code === 0 25: } catch (_) { 26: return false 27: } 28: } 29: export async function openBrowser(url: string): Promise<boolean> { 30: try { 31: validateUrl(url) 32: const browserEnv = process.env.BROWSER 33: const platform = process.platform 34: if (platform === 'win32') { 35: if (browserEnv) { 36: const { code } = await execFileNoThrow(browserEnv, [`"${url}"`]) 37: return code === 0 38: } 39: const { code } = await execFileNoThrow( 40: 'rundll32', 41: ['url,OpenURL', url], 42: {}, 43: ) 44: return code === 0 45: } else { 46: const command = 47: browserEnv || (platform === 'darwin' ? 'open' : 'xdg-open') 48: const { code } = await execFileNoThrow(command, [url]) 49: return code === 0 50: } 51: } catch (_) { 52: return false 53: } 54: }

File: src/utils/bufferedWriter.ts

typescript 1: type WriteFn = (content: string) => void 2: export type BufferedWriter = { 3: write: (content: string) => void 4: flush: () => void 5: dispose: () => void 6: } 7: export function createBufferedWriter({ 8: writeFn, 9: flushIntervalMs = 1000, 10: maxBufferSize = 100, 11: maxBufferBytes = Infinity, 12: immediateMode = false, 13: }: { 14: writeFn: WriteFn 15: flushIntervalMs?: number 16: maxBufferSize?: number 17: maxBufferBytes?: number 18: immediateMode?: boolean 19: }): BufferedWriter { 20: let buffer: string[] = [] 21: let bufferBytes = 0 22: let flushTimer: NodeJS.Timeout | null = null 23: let pendingOverflow: string[] | null = null 24: function clearTimer(): void { 25: if (flushTimer) { 26: clearTimeout(flushTimer) 27: flushTimer = null 28: } 29: } 30: function flush(): void { 31: if (pendingOverflow) { 32: writeFn(pendingOverflow.join('')) 33: pendingOverflow = null 34: } 35: if (buffer.length === 0) return 36: writeFn(buffer.join('')) 37: buffer = [] 38: bufferBytes = 0 39: clearTimer() 40: } 41: function scheduleFlush(): void { 42: if (!flushTimer) { 43: flushTimer = setTimeout(flush, flushIntervalMs) 44: } 45: } 46: // Detach the buffer synchronously so the caller never waits on writeFn. 47: // writeFn may block (e.g. errorLogSink.ts appendFileSync) — if overflow fires 48: // mid-render or mid-keystroke, deferring the write keeps the current tick 49: // short. Timer-based flushes already run outside user code paths so they 50: // stay synchronous. 51: function flushDeferred(): void { 52: if (pendingOverflow) { 53: // A previous overflow write is still queued. Coalesce into it to 54: // preserve ordering — writes land in a single setImmediate-ordered batch. 55: pendingOverflow.push(...buffer) 56: buffer = [] 57: bufferBytes = 0 58: clearTimer() 59: return 60: } 61: const detached = buffer 62: buffer = [] 63: bufferBytes = 0 64: clearTimer() 65: pendingOverflow = detached 66: setImmediate(() => { 67: const toWrite = pendingOverflow 68: pendingOverflow = null 69: if (toWrite) writeFn(toWrite.join('')) 70: }) 71: } 72: return { 73: write(content: string): void { 74: if (immediateMode) { 75: writeFn(content) 76: return 77: } 78: buffer.push(content) 79: bufferBytes += content.length 80: scheduleFlush() 81: if (buffer.length >= maxBufferSize || bufferBytes >= maxBufferBytes) { 82: flushDeferred() 83: } 84: }, 85: flush, 86: dispose(): void { 87: flush() 88: }, 89: } 90: }

File: src/utils/bundledMode.ts

typescript 1: export function isRunningWithBun(): boolean { 2: return process.versions.bun !== undefined 3: } 4: export function isInBundledMode(): boolean { 5: return ( 6: typeof Bun !== 'undefined' && 7: Array.isArray(Bun.embeddedFiles) && 8: Bun.embeddedFiles.length > 0 9: ) 10: }

File: src/utils/caCerts.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import { logForDebugging } from './debug.js' 3: import { hasNodeOption } from './envUtils.js' 4: import { getFsImplementation } from './fsOperations.js' 5: export const getCACertificates = memoize((): string[] | undefined => { 6: const useSystemCA = 7: hasNodeOption('--use-system-ca') || hasNodeOption('--use-openssl-ca') 8: const extraCertsPath = process.env.NODE_EXTRA_CA_CERTS 9: logForDebugging( 10: `CA certs: useSystemCA=${useSystemCA}, extraCertsPath=${extraCertsPath}`, 11: ) 12: if (!useSystemCA && !extraCertsPath) { 13: return undefined 14: } 15: const tls = require('tls') as typeof import('tls') 16: const certs: string[] = [] 17: if (useSystemCA) { 18: const getCACerts = ( 19: tls as typeof tls & { getCACertificates?: (type: string) => string[] } 20: ).getCACertificates 21: const systemCAs = getCACerts?.('system') 22: if (systemCAs && systemCAs.length > 0) { 23: certs.push(...systemCAs) 24: logForDebugging( 25: `CA certs: Loaded ${certs.length} system CA certificates (--use-system-ca)`, 26: ) 27: } else if (!getCACerts && !extraCertsPath) { 28: logForDebugging( 29: 'CA certs: --use-system-ca set but system CA API unavailable, deferring to runtime', 30: ) 31: return undefined 32: } else { 33: certs.push(...tls.rootCertificates) 34: logForDebugging( 35: `CA certs: Loaded ${certs.length} bundled root certificates as base (--use-system-ca fallback)`, 36: ) 37: } 38: } else { 39: certs.push(...tls.rootCertificates) 40: logForDebugging( 41: `CA certs: Loaded ${certs.length} bundled root certificates as base`, 42: ) 43: } 44: if (extraCertsPath) { 45: try { 46: const extraCert = getFsImplementation().readFileSync(extraCertsPath, { 47: encoding: 'utf8', 48: }) 49: certs.push(extraCert) 50: logForDebugging( 51: `CA certs: Appended extra certificates from NODE_EXTRA_CA_CERTS (${extraCertsPath})`, 52: ) 53: } catch (error) { 54: logForDebugging( 55: `CA certs: Failed to read NODE_EXTRA_CA_CERTS file (${extraCertsPath}): ${error}`, 56: { level: 'error' }, 57: ) 58: } 59: } 60: return certs.length > 0 ? certs : undefined 61: }) 62: export function clearCACertsCache(): void { 63: getCACertificates.cache.clear?.() 64: logForDebugging('Cleared CA certificates cache') 65: }

File: src/utils/caCertsConfig.ts

typescript 1: import { getGlobalConfig } from './config.js' 2: import { logForDebugging } from './debug.js' 3: import { getSettingsForSource } from './settings/settings.js' 4: export function applyExtraCACertsFromConfig(): void { 5: if (process.env.NODE_EXTRA_CA_CERTS) { 6: return 7: } 8: const configPath = getExtraCertsPathFromConfig() 9: if (configPath) { 10: process.env.NODE_EXTRA_CA_CERTS = configPath 11: logForDebugging( 12: `CA certs: Applied NODE_EXTRA_CA_CERTS from config to process.env: ${configPath}`, 13: ) 14: } 15: } 16: function getExtraCertsPathFromConfig(): string | undefined { 17: try { 18: const globalConfig = getGlobalConfig() 19: const globalEnv = globalConfig?.env 20: const settings = getSettingsForSource('userSettings') 21: const settingsEnv = settings?.env 22: logForDebugging( 23: `CA certs: Config fallback - globalEnv keys: ${globalEnv ? Object.keys(globalEnv).join(',') : 'none'}, settingsEnv keys: ${settingsEnv ? Object.keys(settingsEnv).join(',') : 'none'}`, 24: ) 25: const path = 26: settingsEnv?.NODE_EXTRA_CA_CERTS || globalEnv?.NODE_EXTRA_CA_CERTS 27: if (path) { 28: logForDebugging( 29: `CA certs: Found NODE_EXTRA_CA_CERTS in config/settings: ${path}`, 30: ) 31: } 32: return path 33: } catch (error) { 34: logForDebugging(`CA certs: Config fallback failed: ${error}`, { 35: level: 'error', 36: }) 37: return undefined 38: } 39: }

File: src/utils/cachePaths.ts

typescript 1: import envPaths from 'env-paths' 2: import { join } from 'path' 3: import { getFsImplementation } from './fsOperations.js' 4: import { djb2Hash } from './hash.js' 5: const paths = envPaths('claude-cli') 6: const MAX_SANITIZED_LENGTH = 200 7: function sanitizePath(name: string): string { 8: const sanitized = name.replace(/[^a-zA-Z0-9]/g, '-') 9: if (sanitized.length <= MAX_SANITIZED_LENGTH) { 10: return sanitized 11: } 12: return `${sanitized.slice(0, MAX_SANITIZED_LENGTH)}-${Math.abs(djb2Hash(name)).toString(36)}` 13: } 14: function getProjectDir(cwd: string): string { 15: return sanitizePath(cwd) 16: } 17: export const CACHE_PATHS = { 18: baseLogs: () => join(paths.cache, getProjectDir(getFsImplementation().cwd())), 19: errors: () => 20: join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'errors'), 21: messages: () => 22: join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'messages'), 23: mcpLogs: (serverName: string) => 24: join( 25: paths.cache, 26: getProjectDir(getFsImplementation().cwd()), 27: `mcp-logs-${sanitizePath(serverName)}`, 28: ), 29: }

File: src/utils/CircularBuffer.ts

typescript 1: export class CircularBuffer<T> { 2: private buffer: T[] 3: private head = 0 4: private size = 0 5: constructor(private capacity: number) { 6: this.buffer = new Array(capacity) 7: } 8: add(item: T): void { 9: this.buffer[this.head] = item 10: this.head = (this.head + 1) % this.capacity 11: if (this.size < this.capacity) { 12: this.size++ 13: } 14: } 15: addAll(items: T[]): void { 16: for (const item of items) { 17: this.add(item) 18: } 19: } 20: getRecent(count: number): T[] { 21: const result: T[] = [] 22: const start = this.size < this.capacity ? 0 : this.head 23: const available = Math.min(count, this.size) 24: for (let i = 0; i < available; i++) { 25: const index = (start + this.size - available + i) % this.capacity 26: result.push(this.buffer[index]!) 27: } 28: return result 29: } 30: toArray(): T[] { 31: if (this.size === 0) return [] 32: const result: T[] = [] 33: const start = this.size < this.capacity ? 0 : this.head 34: for (let i = 0; i < this.size; i++) { 35: const index = (start + i) % this.capacity 36: result.push(this.buffer[index]!) 37: } 38: return result 39: } 40: clear(): void { 41: this.buffer.length = 0 42: this.head = 0 43: this.size = 0 44: } 45: length(): number { 46: return this.size 47: } 48: }

File: src/utils/classifierApprovals.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { createSignal } from './signal.js' 3: type ClassifierApproval = { 4: classifier: 'bash' | 'auto-mode' 5: matchedRule?: string 6: reason?: string 7: } 8: const CLASSIFIER_APPROVALS = new Map<string, ClassifierApproval>() 9: const CLASSIFIER_CHECKING = new Set<string>() 10: const classifierChecking = createSignal() 11: export function setClassifierApproval( 12: toolUseID: string, 13: matchedRule: string, 14: ): void { 15: if (!feature('BASH_CLASSIFIER')) { 16: return 17: } 18: CLASSIFIER_APPROVALS.set(toolUseID, { 19: classifier: 'bash', 20: matchedRule, 21: }) 22: } 23: export function getClassifierApproval(toolUseID: string): string | undefined { 24: if (!feature('BASH_CLASSIFIER')) { 25: return undefined 26: } 27: const approval = CLASSIFIER_APPROVALS.get(toolUseID) 28: if (!approval || approval.classifier !== 'bash') return undefined 29: return approval.matchedRule 30: } 31: export function setYoloClassifierApproval( 32: toolUseID: string, 33: reason: string, 34: ): void { 35: if (!feature('TRANSCRIPT_CLASSIFIER')) { 36: return 37: } 38: CLASSIFIER_APPROVALS.set(toolUseID, { classifier: 'auto-mode', reason }) 39: } 40: export function getYoloClassifierApproval( 41: toolUseID: string, 42: ): string | undefined { 43: if (!feature('TRANSCRIPT_CLASSIFIER')) { 44: return undefined 45: } 46: const approval = CLASSIFIER_APPROVALS.get(toolUseID) 47: if (!approval || approval.classifier !== 'auto-mode') return undefined 48: return approval.reason 49: } 50: export function setClassifierChecking(toolUseID: string): void { 51: if (!feature('BASH_CLASSIFIER') && !feature('TRANSCRIPT_CLASSIFIER')) return 52: CLASSIFIER_CHECKING.add(toolUseID) 53: classifierChecking.emit() 54: } 55: export function clearClassifierChecking(toolUseID: string): void { 56: if (!feature('BASH_CLASSIFIER') && !feature('TRANSCRIPT_CLASSIFIER')) return 57: CLASSIFIER_CHECKING.delete(toolUseID) 58: classifierChecking.emit() 59: } 60: export const subscribeClassifierChecking = classifierChecking.subscribe 61: export function isClassifierChecking(toolUseID: string): boolean { 62: return CLASSIFIER_CHECKING.has(toolUseID) 63: } 64: export function deleteClassifierApproval(toolUseID: string): void { 65: CLASSIFIER_APPROVALS.delete(toolUseID) 66: } 67: export function clearClassifierApprovals(): void { 68: CLASSIFIER_APPROVALS.clear() 69: CLASSIFIER_CHECKING.clear() 70: classifierChecking.emit() 71: }

File: src/utils/classifierApprovalsHook.ts

typescript 1: import { useSyncExternalStore } from 'react' 2: import { 3: isClassifierChecking, 4: subscribeClassifierChecking, 5: } from './classifierApprovals.js' 6: export function useIsClassifierChecking(toolUseID: string): boolean { 7: return useSyncExternalStore(subscribeClassifierChecking, () => 8: isClassifierChecking(toolUseID), 9: ) 10: }

File: src/utils/claudeCodeHints.ts

typescript 1: import { logForDebugging } from './debug.js' 2: import { createSignal } from './signal.js' 3: export type ClaudeCodeHintType = 'plugin' 4: export type ClaudeCodeHint = { 5: v: number 6: type: ClaudeCodeHintType 7: value: string 8: sourceCommand: string 9: } 10: const SUPPORTED_VERSIONS = new Set([1]) 11: const SUPPORTED_TYPES = new Set<string>(['plugin']) 12: const HINT_TAG_RE = /^[ \t]*<claude-code-hint\s+([^>]*?)\s*\/>[ \t]*$/gm 13: const ATTR_RE = /(\w+)=(?:"([^"]*)"|([^\s/>]+))/g 14: export function extractClaudeCodeHints( 15: output: string, 16: command: string, 17: ): { hints: ClaudeCodeHint[]; stripped: string } { 18: if (!output.includes('<claude-code-hint')) { 19: return { hints: [], stripped: output } 20: } 21: const sourceCommand = firstCommandToken(command) 22: const hints: ClaudeCodeHint[] = [] 23: const stripped = output.replace(HINT_TAG_RE, rawLine => { 24: const attrs = parseAttrs(rawLine) 25: const v = Number(attrs.v) 26: const type = attrs.type 27: const value = attrs.value 28: if (!SUPPORTED_VERSIONS.has(v)) { 29: logForDebugging( 30: `[claudeCodeHints] dropped hint with unsupported v=${attrs.v}`, 31: ) 32: return '' 33: } 34: if (!type || !SUPPORTED_TYPES.has(type)) { 35: logForDebugging( 36: `[claudeCodeHints] dropped hint with unsupported type=${type}`, 37: ) 38: return '' 39: } 40: if (!value) { 41: logForDebugging('[claudeCodeHints] dropped hint with empty value') 42: return '' 43: } 44: hints.push({ v, type: type as ClaudeCodeHintType, value, sourceCommand }) 45: return '' 46: }) 47: // Dropping a matched line leaves a blank line (the surrounding newlines 48: // remain). Collapse runs of blank lines introduced by the replace so the 49: // model-visible output doesn't grow vertical whitespace. 50: const collapsed = 51: hints.length > 0 || stripped !== output 52: ? stripped.replace(/\n{3,}/g, '\n\n') 53: : stripped 54: return { hints, stripped: collapsed } 55: } 56: function parseAttrs(tagBody: string): Record<string, string> { 57: const attrs: Record<string, string> = {} 58: for (const m of tagBody.matchAll(ATTR_RE)) { 59: attrs[m[1]!] = m[2] ?? m[3] ?? '' 60: } 61: return attrs 62: } 63: function firstCommandToken(command: string): string { 64: const trimmed = command.trim() 65: const spaceIdx = trimmed.search(/\s/) 66: return spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx) 67: } 68: let pendingHint: ClaudeCodeHint | null = null 69: let shownThisSession = false 70: const pendingHintChanged = createSignal() 71: const notify = pendingHintChanged.emit 72: export function setPendingHint(hint: ClaudeCodeHint): void { 73: if (shownThisSession) return 74: pendingHint = hint 75: notify() 76: } 77: export function clearPendingHint(): void { 78: if (pendingHint !== null) { 79: pendingHint = null 80: notify() 81: } 82: } 83: export function markShownThisSession(): void { 84: shownThisSession = true 85: } 86: export const subscribeToPendingHint = pendingHintChanged.subscribe 87: export function getPendingHintSnapshot(): ClaudeCodeHint | null { 88: return pendingHint 89: } 90: export function hasShownHintThisSession(): boolean { 91: return shownThisSession 92: } 93: export function _resetClaudeCodeHintStore(): void { 94: pendingHint = null 95: shownThisSession = false 96: } 97: export const _test = { 98: parseAttrs, 99: firstCommandToken, 100: }

File: src/utils/claudeDesktop.ts

typescript 1: import { readdir, readFile, stat } from 'fs/promises' 2: import { homedir } from 'os' 3: import { join } from 'path' 4: import { 5: type McpServerConfig, 6: McpStdioServerConfigSchema, 7: } from '../services/mcp/types.js' 8: import { getErrnoCode } from './errors.js' 9: import { safeParseJSON } from './json.js' 10: import { logError } from './log.js' 11: import { getPlatform, SUPPORTED_PLATFORMS } from './platform.js' 12: export async function getClaudeDesktopConfigPath(): Promise<string> { 13: const platform = getPlatform() 14: if (!SUPPORTED_PLATFORMS.includes(platform)) { 15: throw new Error( 16: `Unsupported platform: ${platform} - Claude Desktop integration only works on macOS and WSL.`, 17: ) 18: } 19: if (platform === 'macos') { 20: return join( 21: homedir(), 22: 'Library', 23: 'Application Support', 24: 'Claude', 25: 'claude_desktop_config.json', 26: ) 27: } 28: const windowsHome = process.env.USERPROFILE 29: ? process.env.USERPROFILE.replace(/\\/g, '/') 30: : null 31: if (windowsHome) { 32: const wslPath = windowsHome.replace(/^[A-Z]:/, '') 33: const configPath = `/mnt/c${wslPath}/AppData/Roaming/Claude/claude_desktop_config.json` 34: // Check if the file exists 35: try { 36: await stat(configPath) 37: return configPath 38: } catch { 39: // File doesn't exist, continue 40: } 41: } 42: try { 43: const usersDir = '/mnt/c/Users' 44: try { 45: const userDirs = await readdir(usersDir, { withFileTypes: true }) 46: for (const user of userDirs) { 47: if ( 48: user.name === 'Public' || 49: user.name === 'Default' || 50: user.name === 'Default User' || 51: user.name === 'All Users' 52: ) { 53: continue 54: } 55: const potentialConfigPath = join( 56: usersDir, 57: user.name, 58: 'AppData', 59: 'Roaming', 60: 'Claude', 61: 'claude_desktop_config.json', 62: ) 63: try { 64: await stat(potentialConfigPath) 65: return potentialConfigPath 66: } catch { 67: } 68: } 69: } catch { 70: } 71: } catch (dirError) { 72: logError(dirError) 73: } 74: throw new Error( 75: 'Could not find Claude Desktop config file in Windows. Make sure Claude Desktop is installed on Windows.', 76: ) 77: } 78: export async function readClaudeDesktopMcpServers(): Promise< 79: Record<string, McpServerConfig> 80: > { 81: if (!SUPPORTED_PLATFORMS.includes(getPlatform())) { 82: throw new Error( 83: 'Unsupported platform - Claude Desktop integration only works on macOS and WSL.', 84: ) 85: } 86: try { 87: const configPath = await getClaudeDesktopConfigPath() 88: let configContent: string 89: try { 90: configContent = await readFile(configPath, { encoding: 'utf8' }) 91: } catch (e: unknown) { 92: const code = getErrnoCode(e) 93: if (code === 'ENOENT') { 94: return {} 95: } 96: throw e 97: } 98: const config = safeParseJSON(configContent) 99: if (!config || typeof config !== 'object') { 100: return {} 101: } 102: const mcpServers = (config as Record<string, unknown>).mcpServers 103: if (!mcpServers || typeof mcpServers !== 'object') { 104: return {} 105: } 106: const servers: Record<string, McpServerConfig> = {} 107: for (const [name, serverConfig] of Object.entries( 108: mcpServers as Record<string, unknown>, 109: )) { 110: if (!serverConfig || typeof serverConfig !== 'object') { 111: continue 112: } 113: const result = McpStdioServerConfigSchema().safeParse(serverConfig) 114: if (result.success) { 115: servers[name] = result.data 116: } 117: } 118: return servers 119: } catch (error) { 120: logError(error) 121: return {} 122: } 123: }

File: src/utils/claudemd.ts

typescript 1: import { feature } from 'bun:bundle' 2: import ignore from 'ignore' 3: import memoize from 'lodash-es/memoize.js' 4: import { Lexer } from 'marked' 5: import { 6: basename, 7: dirname, 8: extname, 9: isAbsolute, 10: join, 11: parse, 12: relative, 13: sep, 14: } from 'path' 15: import picomatch from 'picomatch' 16: import { logEvent } from 'src/services/analytics/index.js' 17: import { 18: getAdditionalDirectoriesForClaudeMd, 19: getOriginalCwd, 20: } from '../bootstrap/state.js' 21: import { truncateEntrypointContent } from '../memdir/memdir.js' 22: import { getAutoMemEntrypoint, isAutoMemoryEnabled } from '../memdir/paths.js' 23: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 24: import { 25: getCurrentProjectConfig, 26: getManagedClaudeRulesDir, 27: getMemoryPath, 28: getUserClaudeRulesDir, 29: } from './config.js' 30: import { logForDebugging } from './debug.js' 31: import { logForDiagnosticsNoPII } from './diagLogs.js' 32: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 33: import { getErrnoCode } from './errors.js' 34: import { normalizePathForComparison } from './file.js' 35: import { cacheKeys, type FileStateCache } from './fileStateCache.js' 36: import { 37: parseFrontmatter, 38: splitPathInFrontmatter, 39: } from './frontmatterParser.js' 40: import { getFsImplementation, safeResolvePath } from './fsOperations.js' 41: import { findCanonicalGitRoot, findGitRoot } from './git.js' 42: import { 43: executeInstructionsLoadedHooks, 44: hasInstructionsLoadedHook, 45: type InstructionsLoadReason, 46: type InstructionsMemoryType, 47: } from './hooks.js' 48: import type { MemoryType } from './memory/types.js' 49: import { expandPath } from './path.js' 50: import { pathInWorkingPath } from './permissions/filesystem.js' 51: import { isSettingSourceEnabled } from './settings/constants.js' 52: import { getInitialSettings } from './settings/settings.js' 53: const teamMemPaths = feature('TEAMMEM') 54: ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js')) 55: : null 56: let hasLoggedInitialLoad = false 57: const MEMORY_INSTRUCTION_PROMPT = 58: 'Codebase and user instructions are shown below. Be sure to adhere to these instructions. IMPORTANT: These instructions OVERRIDE any default behavior and you MUST follow them exactly as written.' 59: export const MAX_MEMORY_CHARACTER_COUNT = 40000 60: const TEXT_FILE_EXTENSIONS = new Set([ 61: '.md', 62: '.txt', 63: '.text', 64: '.json', 65: '.yaml', 66: '.yml', 67: '.toml', 68: '.xml', 69: '.csv', 70: '.html', 71: '.htm', 72: '.css', 73: '.scss', 74: '.sass', 75: '.less', 76: '.js', 77: '.ts', 78: '.tsx', 79: '.jsx', 80: '.mjs', 81: '.cjs', 82: '.mts', 83: '.cts', 84: '.py', 85: '.pyi', 86: '.pyw', 87: '.rb', 88: '.erb', 89: '.rake', 90: '.go', 91: '.rs', 92: '.java', 93: '.kt', 94: '.kts', 95: '.scala', 96: '.c', 97: '.cpp', 98: '.cc', 99: '.cxx', 100: '.h', 101: '.hpp', 102: '.hxx', 103: '.cs', 104: '.swift', 105: '.sh', 106: '.bash', 107: '.zsh', 108: '.fish', 109: '.ps1', 110: '.bat', 111: '.cmd', 112: '.env', 113: '.ini', 114: '.cfg', 115: '.conf', 116: '.config', 117: '.properties', 118: '.sql', 119: '.graphql', 120: '.gql', 121: '.proto', 122: '.vue', 123: '.svelte', 124: '.astro', 125: '.ejs', 126: '.hbs', 127: '.pug', 128: '.jade', 129: '.php', 130: '.pl', 131: '.pm', 132: '.lua', 133: '.r', 134: '.R', 135: '.dart', 136: '.ex', 137: '.exs', 138: '.erl', 139: '.hrl', 140: '.clj', 141: '.cljs', 142: '.cljc', 143: '.edn', 144: '.hs', 145: '.lhs', 146: '.elm', 147: '.ml', 148: '.mli', 149: '.f', 150: '.f90', 151: '.f95', 152: '.for', 153: '.cmake', 154: '.make', 155: '.makefile', 156: '.gradle', 157: '.sbt', 158: '.rst', 159: '.adoc', 160: '.asciidoc', 161: '.org', 162: '.tex', 163: '.latex', 164: '.lock', 165: '.log', 166: '.diff', 167: '.patch', 168: ]) 169: export type MemoryFileInfo = { 170: path: string 171: type: MemoryType 172: content: string 173: parent?: string 174: globs?: string[] 175: contentDiffersFromDisk?: boolean 176: rawContent?: string 177: } 178: function pathInOriginalCwd(path: string): boolean { 179: return pathInWorkingPath(path, getOriginalCwd()) 180: } 181: function parseFrontmatterPaths(rawContent: string): { 182: content: string 183: paths?: string[] 184: } { 185: const { frontmatter, content } = parseFrontmatter(rawContent) 186: if (!frontmatter.paths) { 187: return { content } 188: } 189: const patterns = splitPathInFrontmatter(frontmatter.paths) 190: .map(pattern => { 191: return pattern.endsWith('/**') ? pattern.slice(0, -3) : pattern 192: }) 193: .filter((p: string) => p.length > 0) 194: if (patterns.length === 0 || patterns.every((p: string) => p === '**')) { 195: return { content } 196: } 197: return { content, paths: patterns } 198: } 199: export function stripHtmlComments(content: string): { 200: content: string 201: stripped: boolean 202: } { 203: if (!content.includes('<!--')) { 204: return { content, stripped: false } 205: } 206: return stripHtmlCommentsFromTokens(new Lexer({ gfm: false }).lex(content)) 207: } 208: function stripHtmlCommentsFromTokens(tokens: ReturnType<Lexer['lex']>): { 209: content: string 210: stripped: boolean 211: } { 212: let result = '' 213: let stripped = false 214: // A well-formed HTML comment span. Non-greedy so multiple comments on the 215: // same line are matched independently; [\s\S] to span newlines. 216: const commentSpan = /<!--[\s\S]*?-->/g 217: for (const token of tokens) { 218: if (token.type === 'html') { 219: const trimmed = token.raw.trimStart() 220: if (trimmed.startsWith('<!--') && trimmed.includes('-->')) { 221: const residue = token.raw.replace(commentSpan, '') 222: stripped = true 223: if (residue.trim().length > 0) { 224: // Residual content exists (e.g. `<!-- note --> Use bun`): keep it. 225: result += residue 226: } 227: continue 228: } 229: } 230: result += token.raw 231: } 232: return { content: result, stripped } 233: } 234: /** 235: * Parses raw memory file content into a MemoryFileInfo. Pure function — no I/O. 236: * 237: * When includeBasePath is given, @include paths are resolved in the same lex 238: * pass and returned alongside the parsed file (so processMemoryFile doesn't 239: * need to lex the same content a second time). 240: */ 241: function parseMemoryFileContent( 242: rawContent: string, 243: filePath: string, 244: type: MemoryType, 245: includeBasePath?: string, 246: ): { info: MemoryFileInfo | null; includePaths: string[] } { 247: const ext = extname(filePath).toLowerCase() 248: if (ext && !TEXT_FILE_EXTENSIONS.has(ext)) { 249: logForDebugging(`Skipping non-text file in @include: ${filePath}`) 250: return { info: null, includePaths: [] } 251: } 252: const { content: withoutFrontmatter, paths } = 253: parseFrontmatterPaths(rawContent) 254: const hasComment = withoutFrontmatter.includes('<!--') 255: const tokens = 256: hasComment || includeBasePath !== undefined 257: ? new Lexer({ gfm: false }).lex(withoutFrontmatter) 258: : undefined 259: const strippedContent = 260: hasComment && tokens 261: ? stripHtmlCommentsFromTokens(tokens).content 262: : withoutFrontmatter 263: const includePaths = 264: tokens && includeBasePath !== undefined 265: ? extractIncludePathsFromTokens(tokens, includeBasePath) 266: : [] 267: let finalContent = strippedContent 268: if (type === 'AutoMem' || type === 'TeamMem') { 269: finalContent = truncateEntrypointContent(strippedContent).content 270: } 271: const contentDiffersFromDisk = finalContent !== rawContent 272: return { 273: info: { 274: path: filePath, 275: type, 276: content: finalContent, 277: globs: paths, 278: contentDiffersFromDisk, 279: rawContent: contentDiffersFromDisk ? rawContent : undefined, 280: }, 281: includePaths, 282: } 283: } 284: function handleMemoryFileReadError(error: unknown, filePath: string): void { 285: const code = getErrnoCode(error) 286: if (code === 'ENOENT' || code === 'EISDIR') { 287: return 288: } 289: if (code === 'EACCES') { 290: logEvent('tengu_claude_md_permission_error', { 291: is_access_error: 1, 292: has_home_dir: filePath.includes(getClaudeConfigHomeDir()) ? 1 : 0, 293: }) 294: } 295: } 296: async function safelyReadMemoryFileAsync( 297: filePath: string, 298: type: MemoryType, 299: includeBasePath?: string, 300: ): Promise<{ info: MemoryFileInfo | null; includePaths: string[] }> { 301: try { 302: const fs = getFsImplementation() 303: const rawContent = await fs.readFile(filePath, { encoding: 'utf-8' }) 304: return parseMemoryFileContent(rawContent, filePath, type, includeBasePath) 305: } catch (error) { 306: handleMemoryFileReadError(error, filePath) 307: return { info: null, includePaths: [] } 308: } 309: } 310: type MarkdownToken = { 311: type: string 312: text?: string 313: href?: string 314: tokens?: MarkdownToken[] 315: raw?: string 316: items?: MarkdownToken[] 317: } 318: function extractIncludePathsFromTokens( 319: tokens: ReturnType<Lexer['lex']>, 320: basePath: string, 321: ): string[] { 322: const absolutePaths = new Set<string>() 323: function extractPathsFromText(textContent: string) { 324: const includeRegex = /(?:^|\s)@((?:[^\s\\]|\\ )+)/g 325: let match 326: while ((match = includeRegex.exec(textContent)) !== null) { 327: let path = match[1] 328: if (!path) continue 329: const hashIndex = path.indexOf('#') 330: if (hashIndex !== -1) { 331: path = path.substring(0, hashIndex) 332: } 333: if (!path) continue 334: path = path.replace(/\\ /g, ' ') 335: if (path) { 336: const isValidPath = 337: path.startsWith('./') || 338: path.startsWith('~/') || 339: (path.startsWith('/') && path !== '/') || 340: (!path.startsWith('@') && 341: !path.match(/^[#%^&*()]+/) && 342: path.match(/^[a-zA-Z0-9._-]/)) 343: if (isValidPath) { 344: const resolvedPath = expandPath(path, dirname(basePath)) 345: absolutePaths.add(resolvedPath) 346: } 347: } 348: } 349: } 350: function processElements(elements: MarkdownToken[]) { 351: for (const element of elements) { 352: if (element.type === 'code' || element.type === 'codespan') { 353: continue 354: } 355: if (element.type === 'html') { 356: const raw = element.raw || '' 357: const trimmed = raw.trimStart() 358: if (trimmed.startsWith('<!--') && trimmed.includes('-->')) { 359: const commentSpan = /<!--[\s\S]*?-->/g 360: const residue = raw.replace(commentSpan, '') 361: if (residue.trim().length > 0) { 362: extractPathsFromText(residue) 363: } 364: } 365: continue 366: } 367: // Process text nodes 368: if (element.type === 'text') { 369: extractPathsFromText(element.text || '') 370: } 371: // Recurse into children tokens 372: if (element.tokens) { 373: processElements(element.tokens) 374: } 375: // Special handling for list structures 376: if (element.items) { 377: processElements(element.items) 378: } 379: } 380: } 381: processElements(tokens as MarkdownToken[]) 382: return [...absolutePaths] 383: } 384: const MAX_INCLUDE_DEPTH = 5 385: /** 386: * Checks whether a CLAUDE.md file path is excluded by the claudeMdExcludes setting. 387: * Only applies to User, Project, and Local memory types. 388: * Managed, AutoMem, and TeamMem types are never excluded. 389: * 390: * Matches both the original path and the realpath-resolved path to handle symlinks 391: * (e.g., /tmp -> /private/tmp on macOS). 392: */ 393: function isClaudeMdExcluded(filePath: string, type: MemoryType): boolean { 394: if (type !== 'User' && type !== 'Project' && type !== 'Local') { 395: return false 396: } 397: const patterns = getInitialSettings().claudeMdExcludes 398: if (!patterns || patterns.length === 0) { 399: return false 400: } 401: const matchOpts = { dot: true } 402: const normalizedPath = filePath.replaceAll('\\', '/') 403: // Build an expanded pattern list that includes realpath-resolved versions of 404: // absolute patterns. This handles symlinks like /tmp -> /private/tmp on macOS: 405: // the user writes "/tmp/project/CLAUDE.md" in their exclude, but the system 406: // resolves the CWD to "/private/tmp/project/...", so the file path uses the 407: // real path. By resolving the patterns too, both sides match. 408: const expandedPatterns = resolveExcludePatterns(patterns).filter( 409: p => p.length > 0, 410: ) 411: if (expandedPatterns.length === 0) { 412: return false 413: } 414: return picomatch.isMatch(normalizedPath, expandedPatterns, matchOpts) 415: } 416: /** 417: * Expands exclude patterns by resolving symlinks in absolute path prefixes. 418: * For each absolute pattern (starting with /), tries to resolve the longest 419: * existing directory prefix via realpathSync and adds the resolved version. 420: * Glob patterns (containing *) have their static prefix resolved. 421: */ 422: function resolveExcludePatterns(patterns: string[]): string[] { 423: const fs = getFsImplementation() 424: const expanded: string[] = patterns.map(p => p.replaceAll('\\', '/')) 425: for (const normalized of expanded) { 426: // Only resolve absolute patterns — glob-only patterns like "**/*.md" don't have 427: if (!normalized.startsWith('/')) { 428: continue 429: } 430: const globStart = normalized.search(/[*?{[]/) 431: const staticPrefix = 432: globStart === -1 ? normalized : normalized.slice(0, globStart) 433: const dirToResolve = dirname(staticPrefix) 434: try { 435: const resolvedDir = fs.realpathSync(dirToResolve).replaceAll('\\', '/') 436: if (resolvedDir !== dirToResolve) { 437: const resolvedPattern = 438: resolvedDir + normalized.slice(dirToResolve.length) 439: expanded.push(resolvedPattern) 440: } 441: } catch { 442: // Directory doesn't exist; skip resolution for this pattern 443: } 444: } 445: return expanded 446: } 447: export async function processMemoryFile( 448: filePath: string, 449: type: MemoryType, 450: processedPaths: Set<string>, 451: includeExternal: boolean, 452: depth: number = 0, 453: parent?: string, 454: ): Promise<MemoryFileInfo[]> { 455: const normalizedPath = normalizePathForComparison(filePath) 456: if (processedPaths.has(normalizedPath) || depth >= MAX_INCLUDE_DEPTH) { 457: return [] 458: } 459: if (isClaudeMdExcluded(filePath, type)) { 460: return [] 461: } 462: const { resolvedPath, isSymlink } = safeResolvePath( 463: getFsImplementation(), 464: filePath, 465: ) 466: processedPaths.add(normalizedPath) 467: if (isSymlink) { 468: processedPaths.add(normalizePathForComparison(resolvedPath)) 469: } 470: const { info: memoryFile, includePaths: resolvedIncludePaths } = 471: await safelyReadMemoryFileAsync(filePath, type, resolvedPath) 472: if (!memoryFile || !memoryFile.content.trim()) { 473: return [] 474: } 475: if (parent) { 476: memoryFile.parent = parent 477: } 478: const result: MemoryFileInfo[] = [] 479: result.push(memoryFile) 480: for (const resolvedIncludePath of resolvedIncludePaths) { 481: const isExternal = !pathInOriginalCwd(resolvedIncludePath) 482: if (isExternal && !includeExternal) { 483: continue 484: } 485: const includedFiles = await processMemoryFile( 486: resolvedIncludePath, 487: type, 488: processedPaths, 489: includeExternal, 490: depth + 1, 491: filePath, 492: ) 493: result.push(...includedFiles) 494: } 495: return result 496: } 497: export async function processMdRules({ 498: rulesDir, 499: type, 500: processedPaths, 501: includeExternal, 502: conditionalRule, 503: visitedDirs = new Set(), 504: }: { 505: rulesDir: string 506: type: MemoryType 507: processedPaths: Set<string> 508: includeExternal: boolean 509: conditionalRule: boolean 510: visitedDirs?: Set<string> 511: }): Promise<MemoryFileInfo[]> { 512: if (visitedDirs.has(rulesDir)) { 513: return [] 514: } 515: try { 516: const fs = getFsImplementation() 517: const { resolvedPath: resolvedRulesDir, isSymlink } = safeResolvePath( 518: fs, 519: rulesDir, 520: ) 521: visitedDirs.add(rulesDir) 522: if (isSymlink) { 523: visitedDirs.add(resolvedRulesDir) 524: } 525: const result: MemoryFileInfo[] = [] 526: let entries: import('fs').Dirent[] 527: try { 528: entries = await fs.readdir(resolvedRulesDir) 529: } catch (e: unknown) { 530: const code = getErrnoCode(e) 531: if (code === 'ENOENT' || code === 'EACCES' || code === 'ENOTDIR') { 532: return [] 533: } 534: throw e 535: } 536: for (const entry of entries) { 537: const entryPath = join(rulesDir, entry.name) 538: const { resolvedPath: resolvedEntryPath, isSymlink } = safeResolvePath( 539: fs, 540: entryPath, 541: ) 542: const stats = isSymlink ? await fs.stat(resolvedEntryPath) : null 543: const isDirectory = stats ? stats.isDirectory() : entry.isDirectory() 544: const isFile = stats ? stats.isFile() : entry.isFile() 545: if (isDirectory) { 546: result.push( 547: ...(await processMdRules({ 548: rulesDir: resolvedEntryPath, 549: type, 550: processedPaths, 551: includeExternal, 552: conditionalRule, 553: visitedDirs, 554: })), 555: ) 556: } else if (isFile && entry.name.endsWith('.md')) { 557: const files = await processMemoryFile( 558: resolvedEntryPath, 559: type, 560: processedPaths, 561: includeExternal, 562: ) 563: result.push( 564: ...files.filter(f => (conditionalRule ? f.globs : !f.globs)), 565: ) 566: } 567: } 568: return result 569: } catch (error) { 570: if (error instanceof Error && error.message.includes('EACCES')) { 571: logEvent('tengu_claude_rules_md_permission_error', { 572: is_access_error: 1, 573: has_home_dir: rulesDir.includes(getClaudeConfigHomeDir()) ? 1 : 0, 574: }) 575: } 576: return [] 577: } 578: } 579: export const getMemoryFiles = memoize( 580: async (forceIncludeExternal: boolean = false): Promise<MemoryFileInfo[]> => { 581: const startTime = Date.now() 582: logForDiagnosticsNoPII('info', 'memory_files_started') 583: const result: MemoryFileInfo[] = [] 584: const processedPaths = new Set<string>() 585: const config = getCurrentProjectConfig() 586: const includeExternal = 587: forceIncludeExternal || 588: config.hasClaudeMdExternalIncludesApproved || 589: false 590: const managedClaudeMd = getMemoryPath('Managed') 591: result.push( 592: ...(await processMemoryFile( 593: managedClaudeMd, 594: 'Managed', 595: processedPaths, 596: includeExternal, 597: )), 598: ) 599: const managedClaudeRulesDir = getManagedClaudeRulesDir() 600: result.push( 601: ...(await processMdRules({ 602: rulesDir: managedClaudeRulesDir, 603: type: 'Managed', 604: processedPaths, 605: includeExternal, 606: conditionalRule: false, 607: })), 608: ) 609: if (isSettingSourceEnabled('userSettings')) { 610: const userClaudeMd = getMemoryPath('User') 611: result.push( 612: ...(await processMemoryFile( 613: userClaudeMd, 614: 'User', 615: processedPaths, 616: true, 617: )), 618: ) 619: const userClaudeRulesDir = getUserClaudeRulesDir() 620: result.push( 621: ...(await processMdRules({ 622: rulesDir: userClaudeRulesDir, 623: type: 'User', 624: processedPaths, 625: includeExternal: true, 626: conditionalRule: false, 627: })), 628: ) 629: } 630: const dirs: string[] = [] 631: const originalCwd = getOriginalCwd() 632: let currentDir = originalCwd 633: while (currentDir !== parse(currentDir).root) { 634: dirs.push(currentDir) 635: currentDir = dirname(currentDir) 636: } 637: const gitRoot = findGitRoot(originalCwd) 638: const canonicalRoot = findCanonicalGitRoot(originalCwd) 639: const isNestedWorktree = 640: gitRoot !== null && 641: canonicalRoot !== null && 642: normalizePathForComparison(gitRoot) !== 643: normalizePathForComparison(canonicalRoot) && 644: pathInWorkingPath(gitRoot, canonicalRoot) 645: for (const dir of dirs.reverse()) { 646: const skipProject = 647: isNestedWorktree && 648: pathInWorkingPath(dir, canonicalRoot) && 649: !pathInWorkingPath(dir, gitRoot) 650: if (isSettingSourceEnabled('projectSettings') && !skipProject) { 651: const projectPath = join(dir, 'CLAUDE.md') 652: result.push( 653: ...(await processMemoryFile( 654: projectPath, 655: 'Project', 656: processedPaths, 657: includeExternal, 658: )), 659: ) 660: const dotClaudePath = join(dir, '.claude', 'CLAUDE.md') 661: result.push( 662: ...(await processMemoryFile( 663: dotClaudePath, 664: 'Project', 665: processedPaths, 666: includeExternal, 667: )), 668: ) 669: const rulesDir = join(dir, '.claude', 'rules') 670: result.push( 671: ...(await processMdRules({ 672: rulesDir, 673: type: 'Project', 674: processedPaths, 675: includeExternal, 676: conditionalRule: false, 677: })), 678: ) 679: } 680: if (isSettingSourceEnabled('localSettings')) { 681: const localPath = join(dir, 'CLAUDE.local.md') 682: result.push( 683: ...(await processMemoryFile( 684: localPath, 685: 'Local', 686: processedPaths, 687: includeExternal, 688: )), 689: ) 690: } 691: } 692: if (isEnvTruthy(process.env.CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD)) { 693: const additionalDirs = getAdditionalDirectoriesForClaudeMd() 694: for (const dir of additionalDirs) { 695: const projectPath = join(dir, 'CLAUDE.md') 696: result.push( 697: ...(await processMemoryFile( 698: projectPath, 699: 'Project', 700: processedPaths, 701: includeExternal, 702: )), 703: ) 704: const dotClaudePath = join(dir, '.claude', 'CLAUDE.md') 705: result.push( 706: ...(await processMemoryFile( 707: dotClaudePath, 708: 'Project', 709: processedPaths, 710: includeExternal, 711: )), 712: ) 713: const rulesDir = join(dir, '.claude', 'rules') 714: result.push( 715: ...(await processMdRules({ 716: rulesDir, 717: type: 'Project', 718: processedPaths, 719: includeExternal, 720: conditionalRule: false, 721: })), 722: ) 723: } 724: } 725: if (isAutoMemoryEnabled()) { 726: const { info: memdirEntry } = await safelyReadMemoryFileAsync( 727: getAutoMemEntrypoint(), 728: 'AutoMem', 729: ) 730: if (memdirEntry) { 731: const normalizedPath = normalizePathForComparison(memdirEntry.path) 732: if (!processedPaths.has(normalizedPath)) { 733: processedPaths.add(normalizedPath) 734: result.push(memdirEntry) 735: } 736: } 737: } 738: if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) { 739: const { info: teamMemEntry } = await safelyReadMemoryFileAsync( 740: teamMemPaths!.getTeamMemEntrypoint(), 741: 'TeamMem', 742: ) 743: if (teamMemEntry) { 744: const normalizedPath = normalizePathForComparison(teamMemEntry.path) 745: if (!processedPaths.has(normalizedPath)) { 746: processedPaths.add(normalizedPath) 747: result.push(teamMemEntry) 748: } 749: } 750: } 751: const totalContentLength = result.reduce( 752: (sum, f) => sum + f.content.length, 753: 0, 754: ) 755: logForDiagnosticsNoPII('info', 'memory_files_completed', { 756: duration_ms: Date.now() - startTime, 757: file_count: result.length, 758: total_content_length: totalContentLength, 759: }) 760: const typeCounts: Record<string, number> = {} 761: for (const f of result) { 762: typeCounts[f.type] = (typeCounts[f.type] ?? 0) + 1 763: } 764: if (!hasLoggedInitialLoad) { 765: hasLoggedInitialLoad = true 766: logEvent('tengu_claudemd__initial_load', { 767: file_count: result.length, 768: total_content_length: totalContentLength, 769: user_count: typeCounts['User'] ?? 0, 770: project_count: typeCounts['Project'] ?? 0, 771: local_count: typeCounts['Local'] ?? 0, 772: managed_count: typeCounts['Managed'] ?? 0, 773: automem_count: typeCounts['AutoMem'] ?? 0, 774: ...(feature('TEAMMEM') 775: ? { teammem_count: typeCounts['TeamMem'] ?? 0 } 776: : {}), 777: duration_ms: Date.now() - startTime, 778: }) 779: } 780: if (!forceIncludeExternal) { 781: const eagerLoadReason = consumeNextEagerLoadReason() 782: if (eagerLoadReason !== undefined && hasInstructionsLoadedHook()) { 783: for (const file of result) { 784: if (!isInstructionsMemoryType(file.type)) continue 785: const loadReason = file.parent ? 'include' : eagerLoadReason 786: void executeInstructionsLoadedHooks( 787: file.path, 788: file.type, 789: loadReason, 790: { 791: globs: file.globs, 792: parentFilePath: file.parent, 793: }, 794: ) 795: } 796: } 797: } 798: return result 799: }, 800: ) 801: function isInstructionsMemoryType( 802: type: MemoryType, 803: ): type is InstructionsMemoryType { 804: return ( 805: type === 'User' || 806: type === 'Project' || 807: type === 'Local' || 808: type === 'Managed' 809: ) 810: } 811: let nextEagerLoadReason: InstructionsLoadReason = 'session_start' 812: let shouldFireHook = true 813: function consumeNextEagerLoadReason(): InstructionsLoadReason | undefined { 814: if (!shouldFireHook) return undefined 815: shouldFireHook = false 816: const reason = nextEagerLoadReason 817: nextEagerLoadReason = 'session_start' 818: return reason 819: } 820: export function clearMemoryFileCaches(): void { 821: getMemoryFiles.cache?.clear?.() 822: } 823: export function resetGetMemoryFilesCache( 824: reason: InstructionsLoadReason = 'session_start', 825: ): void { 826: nextEagerLoadReason = reason 827: shouldFireHook = true 828: clearMemoryFileCaches() 829: } 830: export function getLargeMemoryFiles(files: MemoryFileInfo[]): MemoryFileInfo[] { 831: return files.filter(f => f.content.length > MAX_MEMORY_CHARACTER_COUNT) 832: } 833: export function filterInjectedMemoryFiles( 834: files: MemoryFileInfo[], 835: ): MemoryFileInfo[] { 836: const skipMemoryIndex = getFeatureValue_CACHED_MAY_BE_STALE( 837: 'tengu_moth_copse', 838: false, 839: ) 840: if (!skipMemoryIndex) return files 841: return files.filter(f => f.type !== 'AutoMem' && f.type !== 'TeamMem') 842: } 843: export const getClaudeMds = ( 844: memoryFiles: MemoryFileInfo[], 845: filter?: (type: MemoryType) => boolean, 846: ): string => { 847: const memories: string[] = [] 848: const skipProjectLevel = getFeatureValue_CACHED_MAY_BE_STALE( 849: 'tengu_paper_halyard', 850: false, 851: ) 852: for (const file of memoryFiles) { 853: if (filter && !filter(file.type)) continue 854: if (skipProjectLevel && (file.type === 'Project' || file.type === 'Local')) 855: continue 856: if (file.content) { 857: const description = 858: file.type === 'Project' 859: ? ' (project instructions, checked into the codebase)' 860: : file.type === 'Local' 861: ? " (user's private project instructions, not checked in)" 862: : feature('TEAMMEM') && file.type === 'TeamMem' 863: ? ' (shared team memory, synced across the organization)' 864: : file.type === 'AutoMem' 865: ? " (user's auto-memory, persists across conversations)" 866: : " (user's private global instructions for all projects)" 867: const content = file.content.trim() 868: if (feature('TEAMMEM') && file.type === 'TeamMem') { 869: memories.push( 870: `Contents of ${file.path}${description}:\n\n<team-memory-content source="shared">\n${content}\n</team-memory-content>`, 871: ) 872: } else { 873: memories.push(`Contents of ${file.path}${description}:\n\n${content}`) 874: } 875: } 876: } 877: if (memories.length === 0) { 878: return '' 879: } 880: return `${MEMORY_INSTRUCTION_PROMPT}\n\n${memories.join('\n\n')}` 881: } 882: /** 883: * Gets managed and user conditional rules that match the target path. 884: * This is the first phase of nested memory loading. 885: * 886: * @param targetPath The target file path to match against glob patterns 887: * @param processedPaths Set of already processed file paths (will be mutated) 888: * @returns Array of MemoryFileInfo objects for matching conditional rules 889: */ 890: export async function getManagedAndUserConditionalRules( 891: targetPath: string, 892: processedPaths: Set<string>, 893: ): Promise<MemoryFileInfo[]> { 894: const result: MemoryFileInfo[] = [] 895: // Process Managed conditional .claude/rules/*.md files 896: const managedClaudeRulesDir = getManagedClaudeRulesDir() 897: result.push( 898: ...(await processConditionedMdRules( 899: targetPath, 900: managedClaudeRulesDir, 901: 'Managed', 902: processedPaths, 903: false, 904: )), 905: ) 906: if (isSettingSourceEnabled('userSettings')) { 907: // Process User conditional .claude/rules/*.md files 908: const userClaudeRulesDir = getUserClaudeRulesDir() 909: result.push( 910: ...(await processConditionedMdRules( 911: targetPath, 912: userClaudeRulesDir, 913: 'User', 914: processedPaths, 915: true, 916: )), 917: ) 918: } 919: return result 920: } 921: /** 922: * Gets memory files for a single nested directory (between CWD and target). 923: * Loads CLAUDE.md, unconditional rules, and conditional rules for that directory. 924: * 925: * @param dir The directory to process 926: * @param targetPath The target file path (for conditional rule matching) 927: * @param processedPaths Set of already processed file paths (will be mutated) 928: * @returns Array of MemoryFileInfo objects 929: */ 930: export async function getMemoryFilesForNestedDirectory( 931: dir: string, 932: targetPath: string, 933: processedPaths: Set<string>, 934: ): Promise<MemoryFileInfo[]> { 935: const result: MemoryFileInfo[] = [] 936: // Process project memory files (CLAUDE.md and .claude/CLAUDE.md) 937: if (isSettingSourceEnabled('projectSettings')) { 938: const projectPath = join(dir, 'CLAUDE.md') 939: result.push( 940: ...(await processMemoryFile( 941: projectPath, 942: 'Project', 943: processedPaths, 944: false, 945: )), 946: ) 947: const dotClaudePath = join(dir, '.claude', 'CLAUDE.md') 948: result.push( 949: ...(await processMemoryFile( 950: dotClaudePath, 951: 'Project', 952: processedPaths, 953: false, 954: )), 955: ) 956: } 957: // Process local memory file (CLAUDE.local.md) 958: if (isSettingSourceEnabled('localSettings')) { 959: const localPath = join(dir, 'CLAUDE.local.md') 960: result.push( 961: ...(await processMemoryFile(localPath, 'Local', processedPaths, false)), 962: ) 963: } 964: const rulesDir = join(dir, '.claude', 'rules') 965: // Process project unconditional .claude/rules/*.md files, which were not eagerly loaded 966: // Use a separate processedPaths set to avoid marking conditional rule files as processed 967: const unconditionalProcessedPaths = new Set(processedPaths) 968: result.push( 969: ...(await processMdRules({ 970: rulesDir, 971: type: 'Project', 972: processedPaths: unconditionalProcessedPaths, 973: includeExternal: false, 974: conditionalRule: false, 975: })), 976: ) 977: // Process project conditional .claude/rules/*.md files 978: result.push( 979: ...(await processConditionedMdRules( 980: targetPath, 981: rulesDir, 982: 'Project', 983: processedPaths, 984: false, 985: )), 986: ) 987: // processedPaths must be seeded with unconditional paths for subsequent directories 988: for (const path of unconditionalProcessedPaths) { 989: processedPaths.add(path) 990: } 991: return result 992: } 993: /** 994: * Gets conditional rules for a CWD-level directory (from root up to CWD). 995: * Only processes conditional rules since unconditional rules are already loaded eagerly. 996: * 997: * @param dir The directory to process 998: * @param targetPath The target file path (for conditional rule matching) 999: * @param processedPaths Set of already processed file paths (will be mutated) 1000: * @returns Array of MemoryFileInfo objects 1001: */ 1002: export async function getConditionalRulesForCwdLevelDirectory( 1003: dir: string, 1004: targetPath: string, 1005: processedPaths: Set<string>, 1006: ): Promise<MemoryFileInfo[]> { 1007: const rulesDir = join(dir, '.claude', 'rules') 1008: return processConditionedMdRules( 1009: targetPath, 1010: rulesDir, 1011: 'Project', 1012: processedPaths, 1013: false, 1014: ) 1015: } 1016: /** 1017: * Processes all .md files in the .claude/rules/ directory and its subdirectories, 1018: * filtering to only include files with frontmatter paths that match the target path 1019: * @param targetPath The file path to match against frontmatter glob patterns 1020: * @param rulesDir The path to the rules directory 1021: * @param type Type of memory file (User, Project, Local) 1022: * @param processedPaths Set of already processed file paths 1023: * @param includeExternal Whether to include external files 1024: * @returns Array of MemoryFileInfo objects that match the target path 1025: */ 1026: export async function processConditionedMdRules( 1027: targetPath: string, 1028: rulesDir: string, 1029: type: MemoryType, 1030: processedPaths: Set<string>, 1031: includeExternal: boolean, 1032: ): Promise<MemoryFileInfo[]> { 1033: const conditionedRuleMdFiles = await processMdRules({ 1034: rulesDir, 1035: type, 1036: processedPaths, 1037: includeExternal, 1038: conditionalRule: true, 1039: }) 1040: // Filter to only include files whose globs patterns match the targetPath 1041: return conditionedRuleMdFiles.filter(file => { 1042: if (!file.globs || file.globs.length === 0) { 1043: return false 1044: } 1045: // For Project rules: glob patterns are relative to the directory containing .claude 1046: // For Managed/User rules: glob patterns are relative to the original CWD 1047: const baseDir = 1048: type === 'Project' 1049: ? dirname(dirname(rulesDir)) // Parent of .claude 1050: : getOriginalCwd() // Project root for managed/user rules 1051: const relativePath = isAbsolute(targetPath) 1052: ? relative(baseDir, targetPath) 1053: : targetPath 1054: // ignore() throws on empty strings, paths escaping the base (../), 1055: // and absolute paths (Windows cross-drive relative() returns absolute). 1056: // Files outside baseDir can't match baseDir-relative globs anyway. 1057: if ( 1058: !relativePath || 1059: relativePath.startsWith('..') || 1060: isAbsolute(relativePath) 1061: ) { 1062: return false 1063: } 1064: return ignore().add(file.globs).ignores(relativePath) 1065: }) 1066: } 1067: export type ExternalClaudeMdInclude = { 1068: path: string 1069: parent: string 1070: } 1071: export function getExternalClaudeMdIncludes( 1072: files: MemoryFileInfo[], 1073: ): ExternalClaudeMdInclude[] { 1074: const externals: ExternalClaudeMdInclude[] = [] 1075: for (const file of files) { 1076: if (file.type !== 'User' && file.parent && !pathInOriginalCwd(file.path)) { 1077: externals.push({ path: file.path, parent: file.parent }) 1078: } 1079: } 1080: return externals 1081: } 1082: export function hasExternalClaudeMdIncludes(files: MemoryFileInfo[]): boolean { 1083: return getExternalClaudeMdIncludes(files).length > 0 1084: } 1085: export async function shouldShowClaudeMdExternalIncludesWarning(): Promise<boolean> { 1086: const config = getCurrentProjectConfig() 1087: if ( 1088: config.hasClaudeMdExternalIncludesApproved || 1089: config.hasClaudeMdExternalIncludesWarningShown 1090: ) { 1091: return false 1092: } 1093: return hasExternalClaudeMdIncludes(await getMemoryFiles(true)) 1094: } 1095: /** 1096: * Check if a file path is a memory file (CLAUDE.md, CLAUDE.local.md, or .claude/rules/*.md) 1097: */ 1098: export function isMemoryFilePath(filePath: string): boolean { 1099: const name = basename(filePath) 1100: // CLAUDE.md or CLAUDE.local.md anywhere 1101: if (name === 'CLAUDE.md' || name === 'CLAUDE.local.md') { 1102: return true 1103: } 1104: // .md files in .claude/rules/ directories 1105: if ( 1106: name.endsWith('.md') && 1107: filePath.includes(`${sep}.claude${sep}rules${sep}`) 1108: ) { 1109: return true 1110: } 1111: return false 1112: } 1113: export function getAllMemoryFilePaths( 1114: files: MemoryFileInfo[], 1115: readFileState: FileStateCache, 1116: ): string[] { 1117: const paths = new Set<string>() 1118: for (const file of files) { 1119: if (file.content.trim().length > 0) { 1120: paths.add(file.path) 1121: } 1122: } 1123: for (const filePath of cacheKeys(readFileState)) { 1124: if (isMemoryFilePath(filePath)) { 1125: paths.add(filePath) 1126: } 1127: } 1128: return Array.from(paths) 1129: }

File: src/utils/cleanup.ts

typescript 1: import * as fs from 'fs/promises' 2: import { homedir } from 'os' 3: import { join } from 'path' 4: import { logEvent } from '../services/analytics/index.js' 5: import { CACHE_PATHS } from './cachePaths.js' 6: import { logForDebugging } from './debug.js' 7: import { getClaudeConfigHomeDir } from './envUtils.js' 8: import { type FsOperations, getFsImplementation } from './fsOperations.js' 9: import { cleanupOldImageCaches } from './imageStore.js' 10: import * as lockfile from './lockfile.js' 11: import { logError } from './log.js' 12: import { cleanupOldVersions } from './nativeInstaller/index.js' 13: import { cleanupOldPastes } from './pasteStore.js' 14: import { getProjectsDir } from './sessionStorage.js' 15: import { getSettingsWithAllErrors } from './settings/allErrors.js' 16: import { 17: getSettings_DEPRECATED, 18: rawSettingsContainsKey, 19: } from './settings/settings.js' 20: import { TOOL_RESULTS_SUBDIR } from './toolResultStorage.js' 21: import { cleanupStaleAgentWorktrees } from './worktree.js' 22: const DEFAULT_CLEANUP_PERIOD_DAYS = 30 23: function getCutoffDate(): Date { 24: const settings = getSettings_DEPRECATED() || {} 25: const cleanupPeriodDays = 26: settings.cleanupPeriodDays ?? DEFAULT_CLEANUP_PERIOD_DAYS 27: const cleanupPeriodMs = cleanupPeriodDays * 24 * 60 * 60 * 1000 28: return new Date(Date.now() - cleanupPeriodMs) 29: } 30: export type CleanupResult = { 31: messages: number 32: errors: number 33: } 34: export function addCleanupResults( 35: a: CleanupResult, 36: b: CleanupResult, 37: ): CleanupResult { 38: return { 39: messages: a.messages + b.messages, 40: errors: a.errors + b.errors, 41: } 42: } 43: export function convertFileNameToDate(filename: string): Date { 44: const isoStr = filename 45: .split('.')[0]! 46: .replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, 'T$1:$2:$3.$4Z') 47: return new Date(isoStr) 48: } 49: async function cleanupOldFilesInDirectory( 50: dirPath: string, 51: cutoffDate: Date, 52: isMessagePath: boolean, 53: ): Promise<CleanupResult> { 54: const result: CleanupResult = { messages: 0, errors: 0 } 55: try { 56: const files = await getFsImplementation().readdir(dirPath) 57: for (const file of files) { 58: try { 59: const timestamp = convertFileNameToDate(file.name) 60: if (timestamp < cutoffDate) { 61: await getFsImplementation().unlink(join(dirPath, file.name)) 62: if (isMessagePath) { 63: result.messages++ 64: } else { 65: result.errors++ 66: } 67: } 68: } catch (error) { 69: logError(error as Error) 70: } 71: } 72: } catch (error: unknown) { 73: if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { 74: logError(error) 75: } 76: } 77: return result 78: } 79: export async function cleanupOldMessageFiles(): Promise<CleanupResult> { 80: const fsImpl = getFsImplementation() 81: const cutoffDate = getCutoffDate() 82: const errorPath = CACHE_PATHS.errors() 83: const baseCachePath = CACHE_PATHS.baseLogs() 84: let result = await cleanupOldFilesInDirectory(errorPath, cutoffDate, false) 85: try { 86: let dirents 87: try { 88: dirents = await fsImpl.readdir(baseCachePath) 89: } catch { 90: return result 91: } 92: const mcpLogDirs = dirents 93: .filter( 94: dirent => dirent.isDirectory() && dirent.name.startsWith('mcp-logs-'), 95: ) 96: .map(dirent => join(baseCachePath, dirent.name)) 97: for (const mcpLogDir of mcpLogDirs) { 98: result = addCleanupResults( 99: result, 100: await cleanupOldFilesInDirectory(mcpLogDir, cutoffDate, true), 101: ) 102: await tryRmdir(mcpLogDir, fsImpl) 103: } 104: } catch (error: unknown) { 105: if (error instanceof Error && 'code' in error && error.code !== 'ENOENT') { 106: logError(error) 107: } 108: } 109: return result 110: } 111: async function unlinkIfOld( 112: filePath: string, 113: cutoffDate: Date, 114: fsImpl: FsOperations, 115: ): Promise<boolean> { 116: const stats = await fsImpl.stat(filePath) 117: if (stats.mtime < cutoffDate) { 118: await fsImpl.unlink(filePath) 119: return true 120: } 121: return false 122: } 123: async function tryRmdir(dirPath: string, fsImpl: FsOperations): Promise<void> { 124: try { 125: await fsImpl.rmdir(dirPath) 126: } catch { 127: } 128: } 129: export async function cleanupOldSessionFiles(): Promise<CleanupResult> { 130: const cutoffDate = getCutoffDate() 131: const result: CleanupResult = { messages: 0, errors: 0 } 132: const projectsDir = getProjectsDir() 133: const fsImpl = getFsImplementation() 134: let projectDirents 135: try { 136: projectDirents = await fsImpl.readdir(projectsDir) 137: } catch { 138: return result 139: } 140: for (const projectDirent of projectDirents) { 141: if (!projectDirent.isDirectory()) continue 142: const projectDir = join(projectsDir, projectDirent.name) 143: let entries 144: try { 145: entries = await fsImpl.readdir(projectDir) 146: } catch { 147: result.errors++ 148: continue 149: } 150: for (const entry of entries) { 151: if (entry.isFile()) { 152: if (!entry.name.endsWith('.jsonl') && !entry.name.endsWith('.cast')) { 153: continue 154: } 155: try { 156: if ( 157: await unlinkIfOld(join(projectDir, entry.name), cutoffDate, fsImpl) 158: ) { 159: result.messages++ 160: } 161: } catch { 162: result.errors++ 163: } 164: } else if (entry.isDirectory()) { 165: const sessionDir = join(projectDir, entry.name) 166: const toolResultsDir = join(sessionDir, TOOL_RESULTS_SUBDIR) 167: let toolDirs 168: try { 169: toolDirs = await fsImpl.readdir(toolResultsDir) 170: } catch { 171: await tryRmdir(sessionDir, fsImpl) 172: continue 173: } 174: for (const toolEntry of toolDirs) { 175: if (toolEntry.isFile()) { 176: try { 177: if ( 178: await unlinkIfOld( 179: join(toolResultsDir, toolEntry.name), 180: cutoffDate, 181: fsImpl, 182: ) 183: ) { 184: result.messages++ 185: } 186: } catch { 187: result.errors++ 188: } 189: } else if (toolEntry.isDirectory()) { 190: const toolDirPath = join(toolResultsDir, toolEntry.name) 191: let toolFiles 192: try { 193: toolFiles = await fsImpl.readdir(toolDirPath) 194: } catch { 195: continue 196: } 197: for (const tf of toolFiles) { 198: if (!tf.isFile()) continue 199: try { 200: if ( 201: await unlinkIfOld( 202: join(toolDirPath, tf.name), 203: cutoffDate, 204: fsImpl, 205: ) 206: ) { 207: result.messages++ 208: } 209: } catch { 210: result.errors++ 211: } 212: } 213: await tryRmdir(toolDirPath, fsImpl) 214: } 215: } 216: await tryRmdir(toolResultsDir, fsImpl) 217: await tryRmdir(sessionDir, fsImpl) 218: } 219: } 220: await tryRmdir(projectDir, fsImpl) 221: } 222: return result 223: } 224: async function cleanupSingleDirectory( 225: dirPath: string, 226: extension: string, 227: removeEmptyDir: boolean = true, 228: ): Promise<CleanupResult> { 229: const cutoffDate = getCutoffDate() 230: const result: CleanupResult = { messages: 0, errors: 0 } 231: const fsImpl = getFsImplementation() 232: let dirents 233: try { 234: dirents = await fsImpl.readdir(dirPath) 235: } catch { 236: return result 237: } 238: for (const dirent of dirents) { 239: if (!dirent.isFile() || !dirent.name.endsWith(extension)) continue 240: try { 241: if (await unlinkIfOld(join(dirPath, dirent.name), cutoffDate, fsImpl)) { 242: result.messages++ 243: } 244: } catch { 245: result.errors++ 246: } 247: } 248: if (removeEmptyDir) { 249: await tryRmdir(dirPath, fsImpl) 250: } 251: return result 252: } 253: export function cleanupOldPlanFiles(): Promise<CleanupResult> { 254: const plansDir = join(getClaudeConfigHomeDir(), 'plans') 255: return cleanupSingleDirectory(plansDir, '.md') 256: } 257: export async function cleanupOldFileHistoryBackups(): Promise<CleanupResult> { 258: const cutoffDate = getCutoffDate() 259: const result: CleanupResult = { messages: 0, errors: 0 } 260: const fsImpl = getFsImplementation() 261: try { 262: const configDir = getClaudeConfigHomeDir() 263: const fileHistoryStorageDir = join(configDir, 'file-history') 264: let dirents 265: try { 266: dirents = await fsImpl.readdir(fileHistoryStorageDir) 267: } catch { 268: return result 269: } 270: const fileHistorySessionsDirs = dirents 271: .filter(dirent => dirent.isDirectory()) 272: .map(dirent => join(fileHistoryStorageDir, dirent.name)) 273: await Promise.all( 274: fileHistorySessionsDirs.map(async fileHistorySessionDir => { 275: try { 276: const stats = await fsImpl.stat(fileHistorySessionDir) 277: if (stats.mtime < cutoffDate) { 278: await fsImpl.rm(fileHistorySessionDir, { 279: recursive: true, 280: force: true, 281: }) 282: result.messages++ 283: } 284: } catch { 285: result.errors++ 286: } 287: }), 288: ) 289: await tryRmdir(fileHistoryStorageDir, fsImpl) 290: } catch (error) { 291: logError(error as Error) 292: } 293: return result 294: } 295: export async function cleanupOldSessionEnvDirs(): Promise<CleanupResult> { 296: const cutoffDate = getCutoffDate() 297: const result: CleanupResult = { messages: 0, errors: 0 } 298: const fsImpl = getFsImplementation() 299: try { 300: const configDir = getClaudeConfigHomeDir() 301: const sessionEnvBaseDir = join(configDir, 'session-env') 302: let dirents 303: try { 304: dirents = await fsImpl.readdir(sessionEnvBaseDir) 305: } catch { 306: return result 307: } 308: const sessionEnvDirs = dirents 309: .filter(dirent => dirent.isDirectory()) 310: .map(dirent => join(sessionEnvBaseDir, dirent.name)) 311: for (const sessionEnvDir of sessionEnvDirs) { 312: try { 313: const stats = await fsImpl.stat(sessionEnvDir) 314: if (stats.mtime < cutoffDate) { 315: await fsImpl.rm(sessionEnvDir, { recursive: true, force: true }) 316: result.messages++ 317: } 318: } catch { 319: result.errors++ 320: } 321: } 322: await tryRmdir(sessionEnvBaseDir, fsImpl) 323: } catch (error) { 324: logError(error as Error) 325: } 326: return result 327: } 328: export async function cleanupOldDebugLogs(): Promise<CleanupResult> { 329: const cutoffDate = getCutoffDate() 330: const result: CleanupResult = { messages: 0, errors: 0 } 331: const fsImpl = getFsImplementation() 332: const debugDir = join(getClaudeConfigHomeDir(), 'debug') 333: let dirents 334: try { 335: dirents = await fsImpl.readdir(debugDir) 336: } catch { 337: return result 338: } 339: for (const dirent of dirents) { 340: if ( 341: !dirent.isFile() || 342: !dirent.name.endsWith('.txt') || 343: dirent.name === 'latest' 344: ) { 345: continue 346: } 347: try { 348: if (await unlinkIfOld(join(debugDir, dirent.name), cutoffDate, fsImpl)) { 349: result.messages++ 350: } 351: } catch { 352: result.errors++ 353: } 354: } 355: return result 356: } 357: const ONE_DAY_MS = 24 * 60 * 60 * 1000 358: export async function cleanupNpmCacheForAnthropicPackages(): Promise<void> { 359: const markerPath = join(getClaudeConfigHomeDir(), '.npm-cache-cleanup') 360: try { 361: const stat = await fs.stat(markerPath) 362: if (Date.now() - stat.mtimeMs < ONE_DAY_MS) { 363: logForDebugging('npm cache cleanup: skipping, ran recently') 364: return 365: } 366: } catch { 367: } 368: try { 369: await lockfile.lock(markerPath, { retries: 0, realpath: false }) 370: } catch { 371: logForDebugging('npm cache cleanup: skipping, lock held') 372: return 373: } 374: logForDebugging('npm cache cleanup: starting') 375: const npmCachePath = join(homedir(), '.npm', '_cacache') 376: const NPM_CACHE_RETENTION_COUNT = 5 377: const startTime = Date.now() 378: try { 379: const cacache = await import('cacache') 380: const cutoff = startTime - ONE_DAY_MS 381: const stream = cacache.ls.stream(npmCachePath) 382: const anthropicEntries: { key: string; time: number }[] = [] 383: for await (const entry of stream as AsyncIterable<{ 384: key: string 385: time: number 386: }>) { 387: if (entry.key.includes('@anthropic-ai/claude-')) { 388: anthropicEntries.push({ key: entry.key, time: entry.time }) 389: } 390: } 391: const byPackage = new Map<string, { key: string; time: number }[]>() 392: for (const entry of anthropicEntries) { 393: const atVersionIdx = entry.key.lastIndexOf('@') 394: const pkgName = 395: atVersionIdx > 0 ? entry.key.slice(0, atVersionIdx) : entry.key 396: const existing = byPackage.get(pkgName) ?? [] 397: existing.push(entry) 398: byPackage.set(pkgName, existing) 399: } 400: const keysToRemove: string[] = [] 401: for (const [, entries] of byPackage) { 402: entries.sort((a, b) => b.time - a.time) 403: for (let i = 0; i < entries.length; i++) { 404: const entry = entries[i]! 405: if (entry.time < cutoff || i >= NPM_CACHE_RETENTION_COUNT) { 406: keysToRemove.push(entry.key) 407: } 408: } 409: } 410: await Promise.all( 411: keysToRemove.map(key => cacache.rm.entry(npmCachePath, key)), 412: ) 413: await fs.writeFile(markerPath, new Date().toISOString()) 414: const durationMs = Date.now() - startTime 415: if (keysToRemove.length > 0) { 416: logForDebugging( 417: `npm cache cleanup: Removed ${keysToRemove.length} old @anthropic-ai entries in ${durationMs}ms`, 418: ) 419: } else { 420: logForDebugging(`npm cache cleanup: completed in ${durationMs}ms`) 421: } 422: logEvent('tengu_npm_cache_cleanup', { 423: success: true, 424: durationMs, 425: entriesRemoved: keysToRemove.length, 426: }) 427: } catch (error) { 428: logError(error as Error) 429: logEvent('tengu_npm_cache_cleanup', { 430: success: false, 431: durationMs: Date.now() - startTime, 432: }) 433: } finally { 434: await lockfile.unlock(markerPath, { realpath: false }).catch(() => {}) 435: } 436: } 437: export async function cleanupOldVersionsThrottled(): Promise<void> { 438: const markerPath = join(getClaudeConfigHomeDir(), '.version-cleanup') 439: try { 440: const stat = await fs.stat(markerPath) 441: if (Date.now() - stat.mtimeMs < ONE_DAY_MS) { 442: logForDebugging('version cleanup: skipping, ran recently') 443: return 444: } 445: } catch { 446: } 447: try { 448: await lockfile.lock(markerPath, { retries: 0, realpath: false }) 449: } catch { 450: logForDebugging('version cleanup: skipping, lock held') 451: return 452: } 453: logForDebugging('version cleanup: starting (throttled)') 454: try { 455: await cleanupOldVersions() 456: await fs.writeFile(markerPath, new Date().toISOString()) 457: } catch (error) { 458: logError(error as Error) 459: } finally { 460: await lockfile.unlock(markerPath, { realpath: false }).catch(() => {}) 461: } 462: } 463: export async function cleanupOldMessageFilesInBackground(): Promise<void> { 464: const { errors } = getSettingsWithAllErrors() 465: if (errors.length > 0 && rawSettingsContainsKey('cleanupPeriodDays')) { 466: logForDebugging( 467: 'Skipping cleanup: settings have validation errors but cleanupPeriodDays was explicitly set. Fix settings errors to enable cleanup.', 468: ) 469: return 470: } 471: await cleanupOldMessageFiles() 472: await cleanupOldSessionFiles() 473: await cleanupOldPlanFiles() 474: await cleanupOldFileHistoryBackups() 475: await cleanupOldSessionEnvDirs() 476: await cleanupOldDebugLogs() 477: await cleanupOldImageCaches() 478: await cleanupOldPastes(getCutoffDate()) 479: const removedWorktrees = await cleanupStaleAgentWorktrees(getCutoffDate()) 480: if (removedWorktrees > 0) { 481: logEvent('tengu_worktree_cleanup', { removed: removedWorktrees }) 482: } 483: if (process.env.USER_TYPE === 'ant') { 484: await cleanupNpmCacheForAnthropicPackages() 485: } 486: }

File: src/utils/cleanupRegistry.ts

typescript 1: const cleanupFunctions = new Set<() => Promise<void>>() 2: export function registerCleanup(cleanupFn: () => Promise<void>): () => void { 3: cleanupFunctions.add(cleanupFn) 4: return () => cleanupFunctions.delete(cleanupFn) 5: } 6: export async function runCleanupFunctions(): Promise<void> { 7: await Promise.all(Array.from(cleanupFunctions).map(fn => fn())) 8: }

File: src/utils/cliArgs.ts

typescript 1: export function eagerParseCliFlag( 2: flagName: string, 3: argv: string[] = process.argv, 4: ): string | undefined { 5: for (let i = 0; i < argv.length; i++) { 6: const arg = argv[i] 7: if (arg?.startsWith(`${flagName}=`)) { 8: return arg.slice(flagName.length + 1) 9: } 10: if (arg === flagName && i + 1 < argv.length) { 11: return argv[i + 1] 12: } 13: } 14: return undefined 15: } 16: export function extractArgsAfterDoubleDash( 17: commandOrValue: string, 18: args: string[] = [], 19: ): { command: string; args: string[] } { 20: if (commandOrValue === '--' && args.length > 0) { 21: return { 22: command: args[0]!, 23: args: args.slice(1), 24: } 25: } 26: return { command: commandOrValue, args } 27: }

File: src/utils/cliHighlight.ts

typescript 1: import { extname } from 'path' 2: export type CliHighlight = { 3: highlight: typeof import('cli-highlight').highlight 4: supportsLanguage: typeof import('cli-highlight').supportsLanguage 5: } 6: let cliHighlightPromise: Promise<CliHighlight | null> | undefined 7: let loadedGetLanguage: typeof import('highlight.js').getLanguage | undefined 8: async function loadCliHighlight(): Promise<CliHighlight | null> { 9: try { 10: const cliHighlight = await import('cli-highlight') 11: const highlightJs = await import('highlight.js') 12: loadedGetLanguage = highlightJs.getLanguage 13: return { 14: highlight: cliHighlight.highlight, 15: supportsLanguage: cliHighlight.supportsLanguage, 16: } 17: } catch { 18: return null 19: } 20: } 21: export function getCliHighlightPromise(): Promise<CliHighlight | null> { 22: cliHighlightPromise ??= loadCliHighlight() 23: return cliHighlightPromise 24: } 25: export async function getLanguageName(file_path: string): Promise<string> { 26: await getCliHighlightPromise() 27: const ext = extname(file_path).slice(1) 28: if (!ext) return 'unknown' 29: return loadedGetLanguage?.(ext)?.name ?? 'unknown' 30: }

File: src/utils/codeIndexing.ts

typescript 1: export type CodeIndexingTool = 2: | 'sourcegraph' 3: | 'hound' 4: | 'seagoat' 5: | 'bloop' 6: | 'gitloop' 7: | 'cody' 8: | 'aider' 9: | 'continue' 10: | 'github-copilot' 11: | 'cursor' 12: | 'tabby' 13: | 'codeium' 14: | 'tabnine' 15: | 'augment' 16: | 'windsurf' 17: | 'aide' 18: | 'pieces' 19: | 'qodo' 20: | 'amazon-q' 21: | 'gemini' 22: | 'claude-context' 23: | 'code-index-mcp' 24: | 'local-code-search' 25: | 'autodev-codebase' 26: | 'openctx' 27: const CLI_COMMAND_MAPPING: Record<string, CodeIndexingTool> = { 28: src: 'sourcegraph', 29: cody: 'cody', 30: aider: 'aider', 31: tabby: 'tabby', 32: tabnine: 'tabnine', 33: augment: 'augment', 34: pieces: 'pieces', 35: qodo: 'qodo', 36: aide: 'aide', 37: hound: 'hound', 38: seagoat: 'seagoat', 39: bloop: 'bloop', 40: gitloop: 'gitloop', 41: q: 'amazon-q', 42: gemini: 'gemini', 43: } 44: const MCP_SERVER_PATTERNS: Array<{ 45: pattern: RegExp 46: tool: CodeIndexingTool 47: }> = [ 48: { pattern: /^sourcegraph$/i, tool: 'sourcegraph' }, 49: { pattern: /^cody$/i, tool: 'cody' }, 50: { pattern: /^openctx$/i, tool: 'openctx' }, 51: { pattern: /^aider$/i, tool: 'aider' }, 52: { pattern: /^continue$/i, tool: 'continue' }, 53: { pattern: /^github[-_]?copilot$/i, tool: 'github-copilot' }, 54: { pattern: /^copilot$/i, tool: 'github-copilot' }, 55: { pattern: /^cursor$/i, tool: 'cursor' }, 56: { pattern: /^tabby$/i, tool: 'tabby' }, 57: { pattern: /^codeium$/i, tool: 'codeium' }, 58: { pattern: /^tabnine$/i, tool: 'tabnine' }, 59: { pattern: /^augment[-_]?code$/i, tool: 'augment' }, 60: { pattern: /^augment$/i, tool: 'augment' }, 61: { pattern: /^windsurf$/i, tool: 'windsurf' }, 62: { pattern: /^aide$/i, tool: 'aide' }, 63: { pattern: /^codestory$/i, tool: 'aide' }, 64: { pattern: /^pieces$/i, tool: 'pieces' }, 65: { pattern: /^qodo$/i, tool: 'qodo' }, 66: { pattern: /^amazon[-_]?q$/i, tool: 'amazon-q' }, 67: { pattern: /^gemini[-_]?code[-_]?assist$/i, tool: 'gemini' }, 68: { pattern: /^gemini$/i, tool: 'gemini' }, 69: { pattern: /^hound$/i, tool: 'hound' }, 70: { pattern: /^seagoat$/i, tool: 'seagoat' }, 71: { pattern: /^bloop$/i, tool: 'bloop' }, 72: { pattern: /^gitloop$/i, tool: 'gitloop' }, 73: { pattern: /^claude[-_]?context$/i, tool: 'claude-context' }, 74: { pattern: /^code[-_]?index[-_]?mcp$/i, tool: 'code-index-mcp' }, 75: { pattern: /^code[-_]?index$/i, tool: 'code-index-mcp' }, 76: { pattern: /^local[-_]?code[-_]?search$/i, tool: 'local-code-search' }, 77: { pattern: /^codebase$/i, tool: 'autodev-codebase' }, 78: { pattern: /^autodev[-_]?codebase$/i, tool: 'autodev-codebase' }, 79: { pattern: /^code[-_]?context$/i, tool: 'claude-context' }, 80: ] 81: export function detectCodeIndexingFromCommand( 82: command: string, 83: ): CodeIndexingTool | undefined { 84: const trimmed = command.trim() 85: const firstWord = trimmed.split(/\s+/)[0]?.toLowerCase() 86: if (!firstWord) { 87: return undefined 88: } 89: if (firstWord === 'npx' || firstWord === 'bunx') { 90: const secondWord = trimmed.split(/\s+/)[1]?.toLowerCase() 91: if (secondWord && secondWord in CLI_COMMAND_MAPPING) { 92: return CLI_COMMAND_MAPPING[secondWord] 93: } 94: } 95: return CLI_COMMAND_MAPPING[firstWord] 96: } 97: export function detectCodeIndexingFromMcpTool( 98: toolName: string, 99: ): CodeIndexingTool | undefined { 100: if (!toolName.startsWith('mcp__')) { 101: return undefined 102: } 103: const parts = toolName.split('__') 104: if (parts.length < 3) { 105: return undefined 106: } 107: const serverName = parts[1] 108: if (!serverName) { 109: return undefined 110: } 111: for (const { pattern, tool } of MCP_SERVER_PATTERNS) { 112: if (pattern.test(serverName)) { 113: return tool 114: } 115: } 116: return undefined 117: } 118: export function detectCodeIndexingFromMcpServerName( 119: serverName: string, 120: ): CodeIndexingTool | undefined { 121: for (const { pattern, tool } of MCP_SERVER_PATTERNS) { 122: if (pattern.test(serverName)) { 123: return tool 124: } 125: } 126: return undefined 127: }

File: src/utils/collapseBackgroundBashNotifications.ts

typescript 1: import { 2: STATUS_TAG, 3: SUMMARY_TAG, 4: TASK_NOTIFICATION_TAG, 5: } from '../constants/xml.js' 6: import { BACKGROUND_BASH_SUMMARY_PREFIX } from '../tasks/LocalShellTask/LocalShellTask.js' 7: import type { 8: NormalizedUserMessage, 9: RenderableMessage, 10: } from '../types/message.js' 11: import { isFullscreenEnvEnabled } from './fullscreen.js' 12: import { extractTag } from './messages.js' 13: function isCompletedBackgroundBash( 14: msg: RenderableMessage, 15: ): msg is NormalizedUserMessage { 16: if (msg.type !== 'user') return false 17: const content = msg.message.content[0] 18: if (content?.type !== 'text') return false 19: if (!content.text.includes(`<${TASK_NOTIFICATION_TAG}`)) return false 20: if (extractTag(content.text, STATUS_TAG) !== 'completed') return false 21: return ( 22: extractTag(content.text, SUMMARY_TAG)?.startsWith( 23: BACKGROUND_BASH_SUMMARY_PREFIX, 24: ) ?? false 25: ) 26: } 27: export function collapseBackgroundBashNotifications( 28: messages: RenderableMessage[], 29: verbose: boolean, 30: ): RenderableMessage[] { 31: if (!isFullscreenEnvEnabled()) return messages 32: if (verbose) return messages 33: const result: RenderableMessage[] = [] 34: let i = 0 35: while (i < messages.length) { 36: const msg = messages[i]! 37: if (isCompletedBackgroundBash(msg)) { 38: let count = 0 39: while (i < messages.length && isCompletedBackgroundBash(messages[i]!)) { 40: count++ 41: i++ 42: } 43: if (count === 1) { 44: result.push(msg) 45: } else { 46: result.push({ 47: ...msg, 48: message: { 49: role: 'user', 50: content: [ 51: { 52: type: 'text', 53: text: `<${TASK_NOTIFICATION_TAG}><${STATUS_TAG}>completed</${STATUS_TAG}><${SUMMARY_TAG}>${count} background commands completed</${SUMMARY_TAG}></${TASK_NOTIFICATION_TAG}>`, 54: }, 55: ], 56: }, 57: }) 58: } 59: } else { 60: result.push(msg) 61: i++ 62: } 63: } 64: return result 65: }

File: src/utils/collapseHookSummaries.ts

typescript 1: import type { 2: RenderableMessage, 3: SystemStopHookSummaryMessage, 4: } from '../types/message.js' 5: function isLabeledHookSummary( 6: msg: RenderableMessage, 7: ): msg is SystemStopHookSummaryMessage { 8: return ( 9: msg.type === 'system' && 10: msg.subtype === 'stop_hook_summary' && 11: msg.hookLabel !== undefined 12: ) 13: } 14: export function collapseHookSummaries( 15: messages: RenderableMessage[], 16: ): RenderableMessage[] { 17: const result: RenderableMessage[] = [] 18: let i = 0 19: while (i < messages.length) { 20: const msg = messages[i]! 21: if (isLabeledHookSummary(msg)) { 22: const label = msg.hookLabel 23: const group: SystemStopHookSummaryMessage[] = [] 24: while (i < messages.length) { 25: const next = messages[i]! 26: if (!isLabeledHookSummary(next) || next.hookLabel !== label) break 27: group.push(next) 28: i++ 29: } 30: if (group.length === 1) { 31: result.push(msg) 32: } else { 33: result.push({ 34: ...msg, 35: hookCount: group.reduce((sum, m) => sum + m.hookCount, 0), 36: hookInfos: group.flatMap(m => m.hookInfos), 37: hookErrors: group.flatMap(m => m.hookErrors), 38: preventedContinuation: group.some(m => m.preventedContinuation), 39: hasOutput: group.some(m => m.hasOutput), 40: totalDurationMs: Math.max(...group.map(m => m.totalDurationMs ?? 0)), 41: }) 42: } 43: } else { 44: result.push(msg) 45: i++ 46: } 47: } 48: return result 49: }

File: src/utils/collapseReadSearch.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { UUID } from 'crypto' 3: import { findToolByName, type Tools } from '../Tool.js' 4: import { extractBashCommentLabel } from '../tools/BashTool/commentLabel.js' 5: import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' 6: import { FILE_EDIT_TOOL_NAME } from '../tools/FileEditTool/constants.js' 7: import { FILE_WRITE_TOOL_NAME } from '../tools/FileWriteTool/prompt.js' 8: import { REPL_TOOL_NAME } from '../tools/REPLTool/constants.js' 9: import { getReplPrimitiveTools } from '../tools/REPLTool/primitiveTools.js' 10: import { 11: type BranchAction, 12: type CommitKind, 13: detectGitOperation, 14: type PrAction, 15: } from '../tools/shared/gitOperationTracking.js' 16: import { TOOL_SEARCH_TOOL_NAME } from '../tools/ToolSearchTool/prompt.js' 17: import type { 18: CollapsedReadSearchGroup, 19: CollapsibleMessage, 20: RenderableMessage, 21: StopHookInfo, 22: SystemStopHookSummaryMessage, 23: } from '../types/message.js' 24: import { getDisplayPath } from './file.js' 25: import { isFullscreenEnvEnabled } from './fullscreen.js' 26: import { 27: isAutoManagedMemoryFile, 28: isAutoManagedMemoryPattern, 29: isMemoryDirectory, 30: isShellCommandTargetingMemory, 31: } from './memoryFileDetection.js' 32: const teamMemOps = feature('TEAMMEM') 33: ? (require('./teamMemoryOps.js') as typeof import('./teamMemoryOps.js')) 34: : null 35: const SNIP_TOOL_NAME = feature('HISTORY_SNIP') 36: ? ( 37: require('../tools/SnipTool/prompt.js') as typeof import('../tools/SnipTool/prompt.js') 38: ).SNIP_TOOL_NAME 39: : null 40: export type SearchOrReadResult = { 41: isCollapsible: boolean 42: isSearch: boolean 43: isRead: boolean 44: isList: boolean 45: isREPL: boolean 46: isMemoryWrite: boolean 47: isAbsorbedSilently: boolean 48: mcpServerName?: string 49: isBash?: boolean 50: } 51: function getFilePathFromToolInput(toolInput: unknown): string | undefined { 52: const input = toolInput as 53: | { file_path?: string; path?: string; pattern?: string; glob?: string } 54: | undefined 55: return input?.file_path ?? input?.path 56: } 57: function isMemorySearch(toolInput: unknown): boolean { 58: const input = toolInput as 59: | { path?: string; pattern?: string; glob?: string; command?: string } 60: | undefined 61: if (!input) { 62: return false 63: } 64: if (input.path) { 65: if (isAutoManagedMemoryFile(input.path) || isMemoryDirectory(input.path)) { 66: return true 67: } 68: } 69: if (input.glob && isAutoManagedMemoryPattern(input.glob)) { 70: return true 71: } 72: if (input.command && isShellCommandTargetingMemory(input.command)) { 73: return true 74: } 75: return false 76: } 77: function isMemoryWriteOrEdit(toolName: string, toolInput: unknown): boolean { 78: if (toolName !== FILE_WRITE_TOOL_NAME && toolName !== FILE_EDIT_TOOL_NAME) { 79: return false 80: } 81: const filePath = getFilePathFromToolInput(toolInput) 82: return filePath !== undefined && isAutoManagedMemoryFile(filePath) 83: } 84: const MAX_HINT_CHARS = 300 85: function commandAsHint(command: string): string { 86: const cleaned = 87: '$ ' + 88: command 89: .split('\n') 90: .map(l => l.replace(/\s+/g, ' ').trim()) 91: .filter(l => l !== '') 92: .join('\n') 93: return cleaned.length > MAX_HINT_CHARS 94: ? cleaned.slice(0, MAX_HINT_CHARS - 1) + '…' 95: : cleaned 96: } 97: export function getToolSearchOrReadInfo( 98: toolName: string, 99: toolInput: unknown, 100: tools: Tools, 101: ): SearchOrReadResult { 102: if (toolName === REPL_TOOL_NAME) { 103: return { 104: isCollapsible: true, 105: isSearch: false, 106: isRead: false, 107: isList: false, 108: isREPL: true, 109: isMemoryWrite: false, 110: isAbsorbedSilently: true, 111: } 112: } 113: if (isMemoryWriteOrEdit(toolName, toolInput)) { 114: return { 115: isCollapsible: true, 116: isSearch: false, 117: isRead: false, 118: isList: false, 119: isREPL: false, 120: isMemoryWrite: true, 121: isAbsorbedSilently: false, 122: } 123: } 124: if ( 125: (feature('HISTORY_SNIP') && toolName === SNIP_TOOL_NAME) || 126: (isFullscreenEnvEnabled() && toolName === TOOL_SEARCH_TOOL_NAME) 127: ) { 128: return { 129: isCollapsible: true, 130: isSearch: false, 131: isRead: false, 132: isList: false, 133: isREPL: false, 134: isMemoryWrite: false, 135: isAbsorbedSilently: true, 136: } 137: } 138: const tool = 139: findToolByName(tools, toolName) ?? 140: findToolByName(getReplPrimitiveTools(), toolName) 141: if (!tool?.isSearchOrReadCommand) { 142: return { 143: isCollapsible: false, 144: isSearch: false, 145: isRead: false, 146: isList: false, 147: isREPL: false, 148: isMemoryWrite: false, 149: isAbsorbedSilently: false, 150: } 151: } 152: const result = tool.isSearchOrReadCommand( 153: toolInput as { [x: string]: unknown }, 154: ) 155: const isList = result.isList ?? false 156: const isCollapsible = result.isSearch || result.isRead || isList 157: return { 158: isCollapsible: 159: isCollapsible || 160: (isFullscreenEnvEnabled() ? toolName === BASH_TOOL_NAME : false), 161: isSearch: result.isSearch, 162: isRead: result.isRead, 163: isList, 164: isREPL: false, 165: isMemoryWrite: false, 166: isAbsorbedSilently: false, 167: ...(tool.isMcp && { mcpServerName: tool.mcpInfo?.serverName }), 168: isBash: isFullscreenEnvEnabled() 169: ? !isCollapsible && toolName === BASH_TOOL_NAME 170: : undefined, 171: } 172: } 173: export function getSearchOrReadFromContent( 174: content: { type: string; name?: string; input?: unknown } | undefined, 175: tools: Tools, 176: ): { 177: isSearch: boolean 178: isRead: boolean 179: isList: boolean 180: isREPL: boolean 181: isMemoryWrite: boolean 182: isAbsorbedSilently: boolean 183: mcpServerName?: string 184: isBash?: boolean 185: } | null { 186: if (content?.type === 'tool_use' && content.name) { 187: const info = getToolSearchOrReadInfo(content.name, content.input, tools) 188: if (info.isCollapsible || info.isREPL) { 189: return { 190: isSearch: info.isSearch, 191: isRead: info.isRead, 192: isList: info.isList, 193: isREPL: info.isREPL, 194: isMemoryWrite: info.isMemoryWrite, 195: isAbsorbedSilently: info.isAbsorbedSilently, 196: mcpServerName: info.mcpServerName, 197: isBash: info.isBash, 198: } 199: } 200: } 201: return null 202: } 203: function isToolSearchOrRead( 204: toolName: string, 205: toolInput: unknown, 206: tools: Tools, 207: ): boolean { 208: return getToolSearchOrReadInfo(toolName, toolInput, tools).isCollapsible 209: } 210: function getCollapsibleToolInfo( 211: msg: RenderableMessage, 212: tools: Tools, 213: ): { 214: name: string 215: input: unknown 216: isSearch: boolean 217: isRead: boolean 218: isList: boolean 219: isREPL: boolean 220: isMemoryWrite: boolean 221: isAbsorbedSilently: boolean 222: mcpServerName?: string 223: isBash?: boolean 224: } | null { 225: if (msg.type === 'assistant') { 226: const content = msg.message.content[0] 227: const info = getSearchOrReadFromContent(content, tools) 228: if (info && content?.type === 'tool_use') { 229: return { name: content.name, input: content.input, ...info } 230: } 231: } 232: if (msg.type === 'grouped_tool_use') { 233: const firstContent = msg.messages[0]?.message.content[0] 234: const info = getSearchOrReadFromContent( 235: firstContent 236: ? { type: 'tool_use', name: msg.toolName, input: firstContent.input } 237: : undefined, 238: tools, 239: ) 240: if (info && firstContent?.type === 'tool_use') { 241: return { name: msg.toolName, input: firstContent.input, ...info } 242: } 243: } 244: return null 245: } 246: function isTextBreaker(msg: RenderableMessage): boolean { 247: if (msg.type === 'assistant') { 248: const content = msg.message.content[0] 249: if (content?.type === 'text' && content.text.trim().length > 0) { 250: return true 251: } 252: } 253: return false 254: } 255: function isNonCollapsibleToolUse( 256: msg: RenderableMessage, 257: tools: Tools, 258: ): boolean { 259: if (msg.type === 'assistant') { 260: const content = msg.message.content[0] 261: if ( 262: content?.type === 'tool_use' && 263: !isToolSearchOrRead(content.name, content.input, tools) 264: ) { 265: return true 266: } 267: } 268: if (msg.type === 'grouped_tool_use') { 269: const firstContent = msg.messages[0]?.message.content[0] 270: if ( 271: firstContent?.type === 'tool_use' && 272: !isToolSearchOrRead(msg.toolName, firstContent.input, tools) 273: ) { 274: return true 275: } 276: } 277: return false 278: } 279: function isPreToolHookSummary( 280: msg: RenderableMessage, 281: ): msg is SystemStopHookSummaryMessage { 282: return ( 283: msg.type === 'system' && 284: msg.subtype === 'stop_hook_summary' && 285: msg.hookLabel === 'PreToolUse' 286: ) 287: } 288: function shouldSkipMessage(msg: RenderableMessage): boolean { 289: if (msg.type === 'assistant') { 290: const content = msg.message.content[0] 291: if (content?.type === 'thinking' || content?.type === 'redacted_thinking') { 292: return true 293: } 294: } 295: if (msg.type === 'attachment') { 296: return true 297: } 298: if (msg.type === 'system') { 299: return true 300: } 301: return false 302: } 303: function isCollapsibleToolUse( 304: msg: RenderableMessage, 305: tools: Tools, 306: ): msg is CollapsibleMessage { 307: if (msg.type === 'assistant') { 308: const content = msg.message.content[0] 309: return ( 310: content?.type === 'tool_use' && 311: isToolSearchOrRead(content.name, content.input, tools) 312: ) 313: } 314: if (msg.type === 'grouped_tool_use') { 315: const firstContent = msg.messages[0]?.message.content[0] 316: return ( 317: firstContent?.type === 'tool_use' && 318: isToolSearchOrRead(msg.toolName, firstContent.input, tools) 319: ) 320: } 321: return false 322: } 323: function isCollapsibleToolResult( 324: msg: RenderableMessage, 325: collapsibleToolUseIds: Set<string>, 326: ): msg is CollapsibleMessage { 327: if (msg.type === 'user') { 328: const toolResults = msg.message.content.filter( 329: (c): c is { type: 'tool_result'; tool_use_id: string } => 330: c.type === 'tool_result', 331: ) 332: return ( 333: toolResults.length > 0 && 334: toolResults.every(r => collapsibleToolUseIds.has(r.tool_use_id)) 335: ) 336: } 337: return false 338: } 339: function getToolUseIdsFromMessage(msg: RenderableMessage): string[] { 340: if (msg.type === 'assistant') { 341: const content = msg.message.content[0] 342: if (content?.type === 'tool_use') { 343: return [content.id] 344: } 345: } 346: if (msg.type === 'grouped_tool_use') { 347: return msg.messages 348: .map(m => { 349: const content = m.message.content[0] 350: return content.type === 'tool_use' ? content.id : '' 351: }) 352: .filter(Boolean) 353: } 354: return [] 355: } 356: /** 357: * Get all tool use IDs from a collapsed read/search group. 358: */ 359: export function getToolUseIdsFromCollapsedGroup( 360: message: CollapsedReadSearchGroup, 361: ): string[] { 362: const ids: string[] = [] 363: for (const msg of message.messages) { 364: ids.push(...getToolUseIdsFromMessage(msg)) 365: } 366: return ids 367: } 368: /** 369: * Check if any tool in a collapsed group is in progress. 370: */ 371: export function hasAnyToolInProgress( 372: message: CollapsedReadSearchGroup, 373: inProgressToolUseIDs: Set<string>, 374: ): boolean { 375: return getToolUseIdsFromCollapsedGroup(message).some(id => 376: inProgressToolUseIDs.has(id), 377: ) 378: } 379: /** 380: * Get the underlying NormalizedMessage for display (timestamp/model). 381: * Handles nested GroupedToolUseMessage within collapsed groups. 382: * Returns a NormalizedAssistantMessage or NormalizedUserMessage (never GroupedToolUseMessage). 383: */ 384: export function getDisplayMessageFromCollapsed( 385: message: CollapsedReadSearchGroup, 386: ): Exclude<CollapsibleMessage, { type: 'grouped_tool_use' }> { 387: const firstMsg = message.displayMessage 388: if (firstMsg.type === 'grouped_tool_use') { 389: return firstMsg.displayMessage 390: } 391: return firstMsg 392: } 393: function countToolUses(msg: RenderableMessage): number { 394: if (msg.type === 'grouped_tool_use') { 395: return msg.messages.length 396: } 397: return 1 398: } 399: function getFilePathsFromReadMessage(msg: RenderableMessage): string[] { 400: const paths: string[] = [] 401: if (msg.type === 'assistant') { 402: const content = msg.message.content[0] 403: if (content?.type === 'tool_use') { 404: const input = content.input as { file_path?: string } | undefined 405: if (input?.file_path) { 406: paths.push(input.file_path) 407: } 408: } 409: } else if (msg.type === 'grouped_tool_use') { 410: for (const m of msg.messages) { 411: const content = m.message.content[0] 412: if (content?.type === 'tool_use') { 413: const input = content.input as { file_path?: string } | undefined 414: if (input?.file_path) { 415: paths.push(input.file_path) 416: } 417: } 418: } 419: } 420: return paths 421: } 422: function scanBashResultForGitOps( 423: msg: CollapsibleMessage, 424: group: GroupAccumulator, 425: ): void { 426: if (msg.type !== 'user') return 427: const out = msg.toolUseResult as 428: | { stdout?: string; stderr?: string } 429: | undefined 430: if (!out?.stdout && !out?.stderr) return 431: const combined = (out.stdout ?? '') + '\n' + (out.stderr ?? '') 432: for (const c of msg.message.content) { 433: if (c.type !== 'tool_result') continue 434: const command = group.bashCommands?.get(c.tool_use_id) 435: if (!command) continue 436: const { commit, push, branch, pr } = detectGitOperation(command, combined) 437: if (commit) group.commits?.push(commit) 438: if (push) group.pushes?.push(push) 439: if (branch) group.branches?.push(branch) 440: if (pr) group.prs?.push(pr) 441: if (commit || push || branch || pr) { 442: group.gitOpBashCount = (group.gitOpBashCount ?? 0) + 1 443: } 444: } 445: } 446: type GroupAccumulator = { 447: messages: CollapsibleMessage[] 448: searchCount: number 449: readFilePaths: Set<string> 450: readOperationCount: number 451: listCount: number 452: toolUseIds: Set<string> 453: memorySearchCount: number 454: memoryReadFilePaths: Set<string> 455: memoryWriteCount: number 456: teamMemorySearchCount?: number 457: teamMemoryReadFilePaths?: Set<string> 458: teamMemoryWriteCount?: number 459: nonMemSearchArgs: string[] 460: latestDisplayHint: string | undefined 461: mcpCallCount?: number 462: mcpServerNames?: Set<string> 463: bashCount?: number 464: bashCommands?: Map<string, string> 465: commits?: { sha: string; kind: CommitKind }[] 466: pushes?: { branch: string }[] 467: branches?: { ref: string; action: BranchAction }[] 468: prs?: { number: number; url?: string; action: PrAction }[] 469: gitOpBashCount?: number 470: hookTotalMs: number 471: hookCount: number 472: hookInfos: StopHookInfo[] 473: relevantMemories?: { path: string; content: string; mtimeMs: number }[] 474: } 475: function createEmptyGroup(): GroupAccumulator { 476: const group: GroupAccumulator = { 477: messages: [], 478: searchCount: 0, 479: readFilePaths: new Set(), 480: readOperationCount: 0, 481: listCount: 0, 482: toolUseIds: new Set(), 483: memorySearchCount: 0, 484: memoryReadFilePaths: new Set(), 485: memoryWriteCount: 0, 486: nonMemSearchArgs: [], 487: latestDisplayHint: undefined, 488: hookTotalMs: 0, 489: hookCount: 0, 490: hookInfos: [], 491: } 492: if (feature('TEAMMEM')) { 493: group.teamMemorySearchCount = 0 494: group.teamMemoryReadFilePaths = new Set() 495: group.teamMemoryWriteCount = 0 496: } 497: group.mcpCallCount = 0 498: group.mcpServerNames = new Set() 499: if (isFullscreenEnvEnabled()) { 500: group.bashCount = 0 501: group.bashCommands = new Map() 502: group.commits = [] 503: group.pushes = [] 504: group.branches = [] 505: group.prs = [] 506: group.gitOpBashCount = 0 507: } 508: return group 509: } 510: function createCollapsedGroup( 511: group: GroupAccumulator, 512: ): CollapsedReadSearchGroup { 513: const firstMsg = group.messages[0]! 514: const totalReadCount = 515: group.readFilePaths.size > 0 516: ? group.readFilePaths.size 517: : group.readOperationCount 518: const toolMemoryReadCount = group.memoryReadFilePaths.size 519: const memoryReadCount = 520: toolMemoryReadCount + (group.relevantMemories?.length ?? 0) 521: const teamMemReadPaths = feature('TEAMMEM') 522: ? group.teamMemoryReadFilePaths 523: : undefined 524: const nonMemReadFilePaths = [...group.readFilePaths].filter( 525: p => 526: !group.memoryReadFilePaths.has(p) && !(teamMemReadPaths?.has(p) ?? false), 527: ) 528: const teamMemSearchCount = feature('TEAMMEM') 529: ? (group.teamMemorySearchCount ?? 0) 530: : 0 531: const teamMemReadCount = feature('TEAMMEM') 532: ? (group.teamMemoryReadFilePaths?.size ?? 0) 533: : 0 534: const teamMemWriteCount = feature('TEAMMEM') 535: ? (group.teamMemoryWriteCount ?? 0) 536: : 0 537: const result: CollapsedReadSearchGroup = { 538: type: 'collapsed_read_search', 539: searchCount: Math.max( 540: 0, 541: group.searchCount - group.memorySearchCount - teamMemSearchCount, 542: ), 543: readCount: Math.max( 544: 0, 545: totalReadCount - toolMemoryReadCount - teamMemReadCount, 546: ), 547: listCount: group.listCount, 548: replCount: 0, 549: memorySearchCount: group.memorySearchCount, 550: memoryReadCount, 551: memoryWriteCount: group.memoryWriteCount, 552: readFilePaths: nonMemReadFilePaths, 553: searchArgs: group.nonMemSearchArgs, 554: latestDisplayHint: group.latestDisplayHint, 555: messages: group.messages, 556: displayMessage: firstMsg, 557: uuid: `collapsed-${firstMsg.uuid}` as UUID, 558: timestamp: firstMsg.timestamp, 559: } 560: if (feature('TEAMMEM')) { 561: result.teamMemorySearchCount = teamMemSearchCount 562: result.teamMemoryReadCount = teamMemReadCount 563: result.teamMemoryWriteCount = teamMemWriteCount 564: } 565: if ((group.mcpCallCount ?? 0) > 0) { 566: result.mcpCallCount = group.mcpCallCount 567: result.mcpServerNames = [...(group.mcpServerNames ?? [])] 568: } 569: if (isFullscreenEnvEnabled()) { 570: if ((group.bashCount ?? 0) > 0) { 571: result.bashCount = group.bashCount 572: result.gitOpBashCount = group.gitOpBashCount 573: } 574: if ((group.commits?.length ?? 0) > 0) result.commits = group.commits 575: if ((group.pushes?.length ?? 0) > 0) result.pushes = group.pushes 576: if ((group.branches?.length ?? 0) > 0) result.branches = group.branches 577: if ((group.prs?.length ?? 0) > 0) result.prs = group.prs 578: } 579: if (group.hookCount > 0) { 580: result.hookTotalMs = group.hookTotalMs 581: result.hookCount = group.hookCount 582: result.hookInfos = group.hookInfos 583: } 584: if (group.relevantMemories && group.relevantMemories.length > 0) { 585: result.relevantMemories = group.relevantMemories 586: } 587: return result 588: } 589: export function collapseReadSearchGroups( 590: messages: RenderableMessage[], 591: tools: Tools, 592: ): RenderableMessage[] { 593: const result: RenderableMessage[] = [] 594: let currentGroup = createEmptyGroup() 595: let deferredSkippable: RenderableMessage[] = [] 596: function flushGroup(): void { 597: if (currentGroup.messages.length === 0) { 598: return 599: } 600: result.push(createCollapsedGroup(currentGroup)) 601: for (const deferred of deferredSkippable) { 602: result.push(deferred) 603: } 604: deferredSkippable = [] 605: currentGroup = createEmptyGroup() 606: } 607: for (const msg of messages) { 608: if (isCollapsibleToolUse(msg, tools)) { 609: const toolInfo = getCollapsibleToolInfo(msg, tools)! 610: if (toolInfo.isMemoryWrite) { 611: const count = countToolUses(msg) 612: if ( 613: feature('TEAMMEM') && 614: teamMemOps?.isTeamMemoryWriteOrEdit(toolInfo.name, toolInfo.input) 615: ) { 616: currentGroup.teamMemoryWriteCount = 617: (currentGroup.teamMemoryWriteCount ?? 0) + count 618: } else { 619: currentGroup.memoryWriteCount += count 620: } 621: } else if (toolInfo.isAbsorbedSilently) { 622: } else if (toolInfo.mcpServerName) { 623: const count = countToolUses(msg) 624: currentGroup.mcpCallCount = (currentGroup.mcpCallCount ?? 0) + count 625: currentGroup.mcpServerNames?.add(toolInfo.mcpServerName) 626: const input = toolInfo.input as { query?: string } | undefined 627: if (input?.query) { 628: currentGroup.latestDisplayHint = `"${input.query}"` 629: } 630: } else if (isFullscreenEnvEnabled() && toolInfo.isBash) { 631: const count = countToolUses(msg) 632: currentGroup.bashCount = (currentGroup.bashCount ?? 0) + count 633: const input = toolInfo.input as { command?: string } | undefined 634: if (input?.command) { 635: currentGroup.latestDisplayHint = 636: extractBashCommentLabel(input.command) ?? 637: commandAsHint(input.command) 638: for (const id of getToolUseIdsFromMessage(msg)) { 639: currentGroup.bashCommands?.set(id, input.command) 640: } 641: } 642: } else if (toolInfo.isList) { 643: currentGroup.listCount += countToolUses(msg) 644: const input = toolInfo.input as { command?: string } | undefined 645: if (input?.command) { 646: currentGroup.latestDisplayHint = commandAsHint(input.command) 647: } 648: } else if (toolInfo.isSearch) { 649: const count = countToolUses(msg) 650: currentGroup.searchCount += count 651: if ( 652: feature('TEAMMEM') && 653: teamMemOps?.isTeamMemorySearch(toolInfo.input) 654: ) { 655: currentGroup.teamMemorySearchCount = 656: (currentGroup.teamMemorySearchCount ?? 0) + count 657: } else if (isMemorySearch(toolInfo.input)) { 658: currentGroup.memorySearchCount += count 659: } else { 660: const input = toolInfo.input as { pattern?: string } | undefined 661: if (input?.pattern) { 662: currentGroup.nonMemSearchArgs.push(input.pattern) 663: currentGroup.latestDisplayHint = `"${input.pattern}"` 664: } 665: } 666: } else { 667: const filePaths = getFilePathsFromReadMessage(msg) 668: for (const filePath of filePaths) { 669: currentGroup.readFilePaths.add(filePath) 670: if (feature('TEAMMEM') && teamMemOps?.isTeamMemFile(filePath)) { 671: currentGroup.teamMemoryReadFilePaths?.add(filePath) 672: } else if (isAutoManagedMemoryFile(filePath)) { 673: currentGroup.memoryReadFilePaths.add(filePath) 674: } else { 675: currentGroup.latestDisplayHint = getDisplayPath(filePath) 676: } 677: } 678: if (filePaths.length === 0) { 679: currentGroup.readOperationCount += countToolUses(msg) 680: const input = toolInfo.input as { command?: string } | undefined 681: if (input?.command) { 682: currentGroup.latestDisplayHint = commandAsHint(input.command) 683: } 684: } 685: } 686: for (const id of getToolUseIdsFromMessage(msg)) { 687: currentGroup.toolUseIds.add(id) 688: } 689: currentGroup.messages.push(msg) 690: } else if (isCollapsibleToolResult(msg, currentGroup.toolUseIds)) { 691: currentGroup.messages.push(msg) 692: if (isFullscreenEnvEnabled() && currentGroup.bashCommands?.size) { 693: scanBashResultForGitOps(msg, currentGroup) 694: } 695: } else if (currentGroup.messages.length > 0 && isPreToolHookSummary(msg)) { 696: currentGroup.hookCount += msg.hookCount 697: currentGroup.hookTotalMs += 698: msg.totalDurationMs ?? 699: msg.hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0) 700: currentGroup.hookInfos.push(...msg.hookInfos) 701: } else if ( 702: currentGroup.messages.length > 0 && 703: msg.type === 'attachment' && 704: msg.attachment.type === 'relevant_memories' 705: ) { 706: currentGroup.relevantMemories ??= [] 707: currentGroup.relevantMemories.push(...msg.attachment.memories) 708: } else if (shouldSkipMessage(msg)) { 709: if ( 710: currentGroup.messages.length > 0 && 711: !(msg.type === 'attachment' && msg.attachment.type === 'nested_memory') 712: ) { 713: deferredSkippable.push(msg) 714: } else { 715: result.push(msg) 716: } 717: } else if (isTextBreaker(msg)) { 718: flushGroup() 719: result.push(msg) 720: } else if (isNonCollapsibleToolUse(msg, tools)) { 721: flushGroup() 722: result.push(msg) 723: } else { 724: flushGroup() 725: result.push(msg) 726: } 727: } 728: flushGroup() 729: return result 730: } 731: export function getSearchReadSummaryText( 732: searchCount: number, 733: readCount: number, 734: isActive: boolean, 735: replCount: number = 0, 736: memoryCounts?: { 737: memorySearchCount: number 738: memoryReadCount: number 739: memoryWriteCount: number 740: teamMemorySearchCount?: number 741: teamMemoryReadCount?: number 742: teamMemoryWriteCount?: number 743: }, 744: listCount: number = 0, 745: ): string { 746: const parts: string[] = [] 747: if (memoryCounts) { 748: const { memorySearchCount, memoryReadCount, memoryWriteCount } = 749: memoryCounts 750: if (memoryReadCount > 0) { 751: const verb = isActive 752: ? parts.length === 0 753: ? 'Recalling' 754: : 'recalling' 755: : parts.length === 0 756: ? 'Recalled' 757: : 'recalled' 758: parts.push( 759: `${verb} ${memoryReadCount} ${memoryReadCount === 1 ? 'memory' : 'memories'}`, 760: ) 761: } 762: if (memorySearchCount > 0) { 763: const verb = isActive 764: ? parts.length === 0 765: ? 'Searching' 766: : 'searching' 767: : parts.length === 0 768: ? 'Searched' 769: : 'searched' 770: parts.push(`${verb} memories`) 771: } 772: if (memoryWriteCount > 0) { 773: const verb = isActive 774: ? parts.length === 0 775: ? 'Writing' 776: : 'writing' 777: : parts.length === 0 778: ? 'Wrote' 779: : 'wrote' 780: parts.push( 781: `${verb} ${memoryWriteCount} ${memoryWriteCount === 1 ? 'memory' : 'memories'}`, 782: ) 783: } 784: if (feature('TEAMMEM') && teamMemOps) { 785: teamMemOps.appendTeamMemorySummaryParts(memoryCounts, isActive, parts) 786: } 787: } 788: if (searchCount > 0) { 789: const searchVerb = isActive 790: ? parts.length === 0 791: ? 'Searching for' 792: : 'searching for' 793: : parts.length === 0 794: ? 'Searched for' 795: : 'searched for' 796: parts.push( 797: `${searchVerb} ${searchCount} ${searchCount === 1 ? 'pattern' : 'patterns'}`, 798: ) 799: } 800: if (readCount > 0) { 801: const readVerb = isActive 802: ? parts.length === 0 803: ? 'Reading' 804: : 'reading' 805: : parts.length === 0 806: ? 'Read' 807: : 'read' 808: parts.push(`${readVerb} ${readCount} ${readCount === 1 ? 'file' : 'files'}`) 809: } 810: if (listCount > 0) { 811: const listVerb = isActive 812: ? parts.length === 0 813: ? 'Listing' 814: : 'listing' 815: : parts.length === 0 816: ? 'Listed' 817: : 'listed' 818: parts.push( 819: `${listVerb} ${listCount} ${listCount === 1 ? 'directory' : 'directories'}`, 820: ) 821: } 822: if (replCount > 0) { 823: const replVerb = isActive ? "REPL'ing" : "REPL'd" 824: parts.push(`${replVerb} ${replCount} ${replCount === 1 ? 'time' : 'times'}`) 825: } 826: const text = parts.join(', ') 827: return isActive ? `${text}…` : text 828: } 829: export function summarizeRecentActivities( 830: activities: readonly { 831: activityDescription?: string 832: isSearch?: boolean 833: isRead?: boolean 834: }[], 835: ): string | undefined { 836: if (activities.length === 0) { 837: return undefined 838: } 839: let searchCount = 0 840: let readCount = 0 841: for (let i = activities.length - 1; i >= 0; i--) { 842: const activity = activities[i]! 843: if (activity.isSearch) { 844: searchCount++ 845: } else if (activity.isRead) { 846: readCount++ 847: } else { 848: break 849: } 850: } 851: const collapsibleCount = searchCount + readCount 852: if (collapsibleCount >= 2) { 853: return getSearchReadSummaryText(searchCount, readCount, true) 854: } 855: for (let i = activities.length - 1; i >= 0; i--) { 856: if (activities[i]?.activityDescription) { 857: return activities[i]!.activityDescription 858: } 859: } 860: return undefined 861: }

File: src/utils/collapseTeammateShutdowns.ts

typescript 1: import type { AttachmentMessage, RenderableMessage } from '../types/message.js' 2: function isTeammateShutdownAttachment( 3: msg: RenderableMessage, 4: ): msg is AttachmentMessage { 5: return ( 6: msg.type === 'attachment' && 7: msg.attachment.type === 'task_status' && 8: msg.attachment.taskType === 'in_process_teammate' && 9: msg.attachment.status === 'completed' 10: ) 11: } 12: export function collapseTeammateShutdowns( 13: messages: RenderableMessage[], 14: ): RenderableMessage[] { 15: const result: RenderableMessage[] = [] 16: let i = 0 17: while (i < messages.length) { 18: const msg = messages[i]! 19: if (isTeammateShutdownAttachment(msg)) { 20: let count = 0 21: while ( 22: i < messages.length && 23: isTeammateShutdownAttachment(messages[i]!) 24: ) { 25: count++ 26: i++ 27: } 28: if (count === 1) { 29: result.push(msg) 30: } else { 31: result.push({ 32: type: 'attachment', 33: uuid: msg.uuid, 34: timestamp: msg.timestamp, 35: attachment: { 36: type: 'teammate_shutdown_batch', 37: count, 38: }, 39: }) 40: } 41: } else { 42: result.push(msg) 43: i++ 44: } 45: } 46: return result 47: }

File: src/utils/combinedAbortSignal.ts

typescript 1: import { createAbortController } from './abortController.js' 2: export function createCombinedAbortSignal( 3: signal: AbortSignal | undefined, 4: opts?: { signalB?: AbortSignal; timeoutMs?: number }, 5: ): { signal: AbortSignal; cleanup: () => void } { 6: const { signalB, timeoutMs } = opts ?? {} 7: const combined = createAbortController() 8: if (signal?.aborted || signalB?.aborted) { 9: combined.abort() 10: return { signal: combined.signal, cleanup: () => {} } 11: } 12: let timer: ReturnType<typeof setTimeout> | undefined 13: const abortCombined = () => { 14: if (timer !== undefined) clearTimeout(timer) 15: combined.abort() 16: } 17: if (timeoutMs !== undefined) { 18: timer = setTimeout(abortCombined, timeoutMs) 19: timer.unref?.() 20: } 21: signal?.addEventListener('abort', abortCombined) 22: signalB?.addEventListener('abort', abortCombined) 23: const cleanup = () => { 24: if (timer !== undefined) clearTimeout(timer) 25: signal?.removeEventListener('abort', abortCombined) 26: signalB?.removeEventListener('abort', abortCombined) 27: } 28: return { signal: combined.signal, cleanup } 29: }

File: src/utils/commandLifecycle.ts

typescript 1: type CommandLifecycleState = 'started' | 'completed' 2: type CommandLifecycleListener = ( 3: uuid: string, 4: state: CommandLifecycleState, 5: ) => void 6: let listener: CommandLifecycleListener | null = null 7: export function setCommandLifecycleListener( 8: cb: CommandLifecycleListener | null, 9: ): void { 10: listener = cb 11: } 12: export function notifyCommandLifecycle( 13: uuid: string, 14: state: CommandLifecycleState, 15: ): void { 16: listener?.(uuid, state) 17: }

File: src/utils/commitAttribution.ts

typescript 1: import { createHash, randomUUID, type UUID } from 'crypto' 2: import { stat } from 'fs/promises' 3: import { isAbsolute, join, relative, sep } from 'path' 4: import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' 5: import type { 6: AttributionSnapshotMessage, 7: FileAttributionState, 8: } from '../types/logs.js' 9: import { getCwd } from './cwd.js' 10: import { logForDebugging } from './debug.js' 11: import { execFileNoThrowWithCwd } from './execFileNoThrow.js' 12: import { getFsImplementation } from './fsOperations.js' 13: import { isGeneratedFile } from './generatedFiles.js' 14: import { getRemoteUrlForDir, resolveGitDir } from './git/gitFilesystem.js' 15: import { findGitRoot, gitExe } from './git.js' 16: import { logError } from './log.js' 17: import { getCanonicalName, type ModelName } from './model/model.js' 18: import { sequential } from './sequential.js' 19: const INTERNAL_MODEL_REPOS = [ 20: 'github.com:anthropics/claude-cli-internal', 21: 'github.com/anthropics/claude-cli-internal', 22: 'github.com:anthropics/anthropic', 23: 'github.com/anthropics/anthropic', 24: 'github.com:anthropics/apps', 25: 'github.com/anthropics/apps', 26: 'github.com:anthropics/casino', 27: 'github.com/anthropics/casino', 28: 'github.com:anthropics/dbt', 29: 'github.com/anthropics/dbt', 30: 'github.com:anthropics/dotfiles', 31: 'github.com/anthropics/dotfiles', 32: 'github.com:anthropics/terraform-config', 33: 'github.com/anthropics/terraform-config', 34: 'github.com:anthropics/hex-export', 35: 'github.com/anthropics/hex-export', 36: 'github.com:anthropics/feedback-v2', 37: 'github.com/anthropics/feedback-v2', 38: 'github.com:anthropics/labs', 39: 'github.com/anthropics/labs', 40: 'github.com:anthropics/argo-rollouts', 41: 'github.com/anthropics/argo-rollouts', 42: 'github.com:anthropics/starling-configs', 43: 'github.com/anthropics/starling-configs', 44: 'github.com:anthropics/ts-tools', 45: 'github.com/anthropics/ts-tools', 46: 'github.com:anthropics/ts-capsules', 47: 'github.com/anthropics/ts-capsules', 48: 'github.com:anthropics/feldspar-testing', 49: 'github.com/anthropics/feldspar-testing', 50: 'github.com:anthropics/trellis', 51: 'github.com/anthropics/trellis', 52: 'github.com:anthropics/claude-for-hiring', 53: 'github.com/anthropics/claude-for-hiring', 54: 'github.com:anthropics/forge-web', 55: 'github.com/anthropics/forge-web', 56: 'github.com:anthropics/infra-manifests', 57: 'github.com/anthropics/infra-manifests', 58: 'github.com:anthropics/mycro_manifests', 59: 'github.com/anthropics/mycro_manifests', 60: 'github.com:anthropics/mycro_configs', 61: 'github.com/anthropics/mycro_configs', 62: 'github.com:anthropics/mobile-apps', 63: 'github.com/anthropics/mobile-apps', 64: ] 65: export function getAttributionRepoRoot(): string { 66: const cwd = getCwd() 67: return findGitRoot(cwd) ?? getOriginalCwd() 68: } 69: let repoClassCache: 'internal' | 'external' | 'none' | null = null 70: export function getRepoClassCached(): 'internal' | 'external' | 'none' | null { 71: return repoClassCache 72: } 73: export function isInternalModelRepoCached(): boolean { 74: return repoClassCache === 'internal' 75: } 76: export const isInternalModelRepo = sequential(async (): Promise<boolean> => { 77: if (repoClassCache !== null) { 78: return repoClassCache === 'internal' 79: } 80: const cwd = getAttributionRepoRoot() 81: const remoteUrl = await getRemoteUrlForDir(cwd) 82: if (!remoteUrl) { 83: repoClassCache = 'none' 84: return false 85: } 86: const isInternal = INTERNAL_MODEL_REPOS.some(repo => remoteUrl.includes(repo)) 87: repoClassCache = isInternal ? 'internal' : 'external' 88: return isInternal 89: }) 90: export function sanitizeSurfaceKey(surfaceKey: string): string { 91: const slashIndex = surfaceKey.lastIndexOf('/') 92: if (slashIndex === -1) { 93: return surfaceKey 94: } 95: const surface = surfaceKey.slice(0, slashIndex) 96: const model = surfaceKey.slice(slashIndex + 1) 97: const sanitizedModel = sanitizeModelName(model) 98: return `${surface}/${sanitizedModel}` 99: } 100: export function sanitizeModelName(shortName: string): string { 101: if (shortName.includes('opus-4-6')) return 'claude-opus-4-6' 102: if (shortName.includes('opus-4-5')) return 'claude-opus-4-5' 103: if (shortName.includes('opus-4-1')) return 'claude-opus-4-1' 104: if (shortName.includes('opus-4')) return 'claude-opus-4' 105: if (shortName.includes('sonnet-4-6')) return 'claude-sonnet-4-6' 106: if (shortName.includes('sonnet-4-5')) return 'claude-sonnet-4-5' 107: if (shortName.includes('sonnet-4')) return 'claude-sonnet-4' 108: if (shortName.includes('sonnet-3-7')) return 'claude-sonnet-3-7' 109: if (shortName.includes('haiku-4-5')) return 'claude-haiku-4-5' 110: if (shortName.includes('haiku-3-5')) return 'claude-haiku-3-5' 111: return 'claude' 112: } 113: export type AttributionState = { 114: fileStates: Map<string, FileAttributionState> 115: sessionBaselines: Map<string, { contentHash: string; mtime: number }> 116: surface: string 117: startingHeadSha: string | null 118: promptCount: number 119: promptCountAtLastCommit: number 120: permissionPromptCount: number 121: permissionPromptCountAtLastCommit: number 122: escapeCount: number 123: escapeCountAtLastCommit: number 124: } 125: export type AttributionSummary = { 126: claudePercent: number 127: claudeChars: number 128: humanChars: number 129: surfaces: string[] 130: } 131: export type FileAttribution = { 132: claudeChars: number 133: humanChars: number 134: percent: number 135: surface: string 136: } 137: export type AttributionData = { 138: version: 1 139: summary: AttributionSummary 140: files: Record<string, FileAttribution> 141: surfaceBreakdown: Record<string, { claudeChars: number; percent: number }> 142: excludedGenerated: string[] 143: sessions: string[] 144: } 145: export function getClientSurface(): string { 146: return process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli' 147: } 148: export function buildSurfaceKey(surface: string, model: ModelName): string { 149: return `${surface}/${getCanonicalName(model)}` 150: } 151: export function computeContentHash(content: string): string { 152: return createHash('sha256').update(content).digest('hex') 153: } 154: export function normalizeFilePath(filePath: string): string { 155: const fs = getFsImplementation() 156: const cwd = getAttributionRepoRoot() 157: if (!isAbsolute(filePath)) { 158: return filePath 159: } 160: let resolvedPath = filePath 161: let resolvedCwd = cwd 162: try { 163: resolvedPath = fs.realpathSync(filePath) 164: } catch { 165: } 166: try { 167: resolvedCwd = fs.realpathSync(cwd) 168: } catch { 169: } 170: if ( 171: resolvedPath.startsWith(resolvedCwd + sep) || 172: resolvedPath === resolvedCwd 173: ) { 174: return relative(resolvedCwd, resolvedPath).replaceAll(sep, '/') 175: } 176: if (filePath.startsWith(cwd + sep) || filePath === cwd) { 177: return relative(cwd, filePath).replaceAll(sep, '/') 178: } 179: return filePath 180: } 181: export function expandFilePath(filePath: string): string { 182: if (isAbsolute(filePath)) { 183: return filePath 184: } 185: return join(getAttributionRepoRoot(), filePath) 186: } 187: export function createEmptyAttributionState(): AttributionState { 188: return { 189: fileStates: new Map(), 190: sessionBaselines: new Map(), 191: surface: getClientSurface(), 192: startingHeadSha: null, 193: promptCount: 0, 194: promptCountAtLastCommit: 0, 195: permissionPromptCount: 0, 196: permissionPromptCountAtLastCommit: 0, 197: escapeCount: 0, 198: escapeCountAtLastCommit: 0, 199: } 200: } 201: function computeFileModificationState( 202: existingFileStates: Map<string, FileAttributionState>, 203: filePath: string, 204: oldContent: string, 205: newContent: string, 206: mtime: number, 207: ): FileAttributionState | null { 208: const normalizedPath = normalizeFilePath(filePath) 209: try { 210: let claudeContribution: number 211: if (oldContent === '' || newContent === '') { 212: // New file or full deletion - contribution is the content length 213: claudeContribution = 214: oldContent === '' ? newContent.length : oldContent.length 215: } else { 216: // Find actual changed region via common prefix/suffix matching. 217: // This correctly handles same-length replacements (e.g., "Esc" → "esc") 218: // where Math.abs(newLen - oldLen) would be 0. 219: const minLen = Math.min(oldContent.length, newContent.length) 220: let prefixEnd = 0 221: while ( 222: prefixEnd < minLen && 223: oldContent[prefixEnd] === newContent[prefixEnd] 224: ) { 225: prefixEnd++ 226: } 227: let suffixLen = 0 228: while ( 229: suffixLen < minLen - prefixEnd && 230: oldContent[oldContent.length - 1 - suffixLen] === 231: newContent[newContent.length - 1 - suffixLen] 232: ) { 233: suffixLen++ 234: } 235: const oldChangedLen = oldContent.length - prefixEnd - suffixLen 236: const newChangedLen = newContent.length - prefixEnd - suffixLen 237: claudeContribution = Math.max(oldChangedLen, newChangedLen) 238: } 239: // Get current file state if it exists 240: const existingState = existingFileStates.get(normalizedPath) 241: const existingContribution = existingState?.claudeContribution ?? 0 242: return { 243: contentHash: computeContentHash(newContent), 244: claudeContribution: existingContribution + claudeContribution, 245: mtime, 246: } 247: } catch (error) { 248: logError(error as Error) 249: return null 250: } 251: } 252: /** 253: * Get a file's modification time (mtimeMs), falling back to Date.now() if 254: * the file doesn't exist. This is async so it can be precomputed before 255: * entering a sync setAppState callback. 256: */ 257: export async function getFileMtime(filePath: string): Promise<number> { 258: const normalizedPath = normalizeFilePath(filePath) 259: const absPath = expandFilePath(normalizedPath) 260: try { 261: const stats = await stat(absPath) 262: return stats.mtimeMs 263: } catch { 264: return Date.now() 265: } 266: } 267: export function trackFileModification( 268: state: AttributionState, 269: filePath: string, 270: oldContent: string, 271: newContent: string, 272: _userModified: boolean, 273: mtime: number = Date.now(), 274: ): AttributionState { 275: const normalizedPath = normalizeFilePath(filePath) 276: const newFileState = computeFileModificationState( 277: state.fileStates, 278: filePath, 279: oldContent, 280: newContent, 281: mtime, 282: ) 283: if (!newFileState) { 284: return state 285: } 286: const newFileStates = new Map(state.fileStates) 287: newFileStates.set(normalizedPath, newFileState) 288: logForDebugging( 289: `Attribution: Tracked ${newFileState.claudeContribution} chars for ${normalizedPath}`, 290: ) 291: return { 292: ...state, 293: fileStates: newFileStates, 294: } 295: } 296: export function trackFileCreation( 297: state: AttributionState, 298: filePath: string, 299: content: string, 300: mtime: number = Date.now(), 301: ): AttributionState { 302: return trackFileModification(state, filePath, '', content, false, mtime) 303: } 304: /** 305: * Track a file deletion by Claude (e.g., via bash rm command). 306: * Used when Claude deletes a file through a non-tracked mechanism. 307: */ 308: export function trackFileDeletion( 309: state: AttributionState, 310: filePath: string, 311: oldContent: string, 312: ): AttributionState { 313: const normalizedPath = normalizeFilePath(filePath) 314: const existingState = state.fileStates.get(normalizedPath) 315: const existingContribution = existingState?.claudeContribution ?? 0 316: const deletedChars = oldContent.length 317: const newFileState: FileAttributionState = { 318: contentHash: '', // Empty hash for deleted files 319: claudeContribution: existingContribution + deletedChars, 320: mtime: Date.now(), 321: } 322: const newFileStates = new Map(state.fileStates) 323: newFileStates.set(normalizedPath, newFileState) 324: logForDebugging( 325: `Attribution: Tracked deletion of ${normalizedPath} (${deletedChars} chars removed, total contribution: ${newFileState.claudeContribution})`, 326: ) 327: return { 328: ...state, 329: fileStates: newFileStates, 330: } 331: } 332: // -- 333: /** 334: * Track multiple file changes in bulk, mutating a single Map copy. 335: * This avoids the O(n²) cost of copying the Map per file when processing 336: * large git diffs (e.g., jj operations that touch hundreds of thousands of files). 337: */ 338: export function trackBulkFileChanges( 339: state: AttributionState, 340: changes: ReadonlyArray<{ 341: path: string 342: type: 'modified' | 'created' | 'deleted' 343: oldContent: string 344: newContent: string 345: mtime?: number 346: }>, 347: ): AttributionState { 348: const newFileStates = new Map(state.fileStates) 349: for (const change of changes) { 350: const mtime = change.mtime ?? Date.now() 351: if (change.type === 'deleted') { 352: const normalizedPath = normalizeFilePath(change.path) 353: const existingState = newFileStates.get(normalizedPath) 354: const existingContribution = existingState?.claudeContribution ?? 0 355: const deletedChars = change.oldContent.length 356: newFileStates.set(normalizedPath, { 357: contentHash: '', 358: claudeContribution: existingContribution + deletedChars, 359: mtime, 360: }) 361: logForDebugging( 362: `Attribution: Tracked deletion of ${normalizedPath} (${deletedChars} chars removed, total contribution: ${existingContribution + deletedChars})`, 363: ) 364: } else { 365: const newFileState = computeFileModificationState( 366: newFileStates, 367: change.path, 368: change.oldContent, 369: change.newContent, 370: mtime, 371: ) 372: if (newFileState) { 373: const normalizedPath = normalizeFilePath(change.path) 374: newFileStates.set(normalizedPath, newFileState) 375: logForDebugging( 376: `Attribution: Tracked ${newFileState.claudeContribution} chars for ${normalizedPath}`, 377: ) 378: } 379: } 380: } 381: return { 382: ...state, 383: fileStates: newFileStates, 384: } 385: } 386: /** 387: * Calculate final attribution for staged files. 388: * Compares session baseline to committed state. 389: */ 390: export async function calculateCommitAttribution( 391: states: AttributionState[], 392: stagedFiles: string[], 393: ): Promise<AttributionData> { 394: const cwd = getAttributionRepoRoot() 395: const sessionId = getSessionId() 396: const files: Record<string, FileAttribution> = {} 397: const excludedGenerated: string[] = [] 398: const surfaces = new Set<string>() 399: const surfaceCounts: Record<string, number> = {} 400: let totalClaudeChars = 0 401: let totalHumanChars = 0 402: // Merge file states from all sessions 403: const mergedFileStates = new Map<string, FileAttributionState>() 404: const mergedBaselines = new Map< 405: string, 406: { contentHash: string; mtime: number } 407: >() 408: for (const state of states) { 409: surfaces.add(state.surface) 410: // Merge baselines (earliest baseline wins) 411: // Handle both Map and plain object (in case of serialization) 412: const baselines = 413: state.sessionBaselines instanceof Map 414: ? state.sessionBaselines 415: : new Map( 416: Object.entries( 417: (state.sessionBaselines ?? {}) as Record< 418: string, 419: { contentHash: string; mtime: number } 420: >, 421: ), 422: ) 423: for (const [path, baseline] of baselines) { 424: if (!mergedBaselines.has(path)) { 425: mergedBaselines.set(path, baseline) 426: } 427: } 428: // Merge file states (accumulate contributions) 429: // Handle both Map and plain object (in case of serialization) 430: const fileStates = 431: state.fileStates instanceof Map 432: ? state.fileStates 433: : new Map( 434: Object.entries( 435: (state.fileStates ?? {}) as Record<string, FileAttributionState>, 436: ), 437: ) 438: for (const [path, fileState] of fileStates) { 439: const existing = mergedFileStates.get(path) 440: if (existing) { 441: mergedFileStates.set(path, { 442: ...fileState, 443: claudeContribution: 444: existing.claudeContribution + fileState.claudeContribution, 445: }) 446: } else { 447: mergedFileStates.set(path, fileState) 448: } 449: } 450: } 451: // Process files in parallel 452: const fileResults = await Promise.all( 453: stagedFiles.map(async file => { 454: // Skip generated files 455: if (isGeneratedFile(file)) { 456: return { type: 'generated' as const, file } 457: } 458: const absPath = join(cwd, file) 459: const fileState = mergedFileStates.get(file) 460: const baseline = mergedBaselines.get(file) 461: const fileSurface = states[0]!.surface 462: let claudeChars = 0 463: let humanChars = 0 464: const deleted = await isFileDeleted(file) 465: if (deleted) { 466: if (fileState) { 467: claudeChars = fileState.claudeContribution 468: humanChars = 0 469: } else { 470: const diffSize = await getGitDiffSize(file) 471: humanChars = diffSize > 0 ? diffSize : 100 472: } 473: } else { 474: try { 475: const stats = await stat(absPath) 476: if (fileState) { 477: claudeChars = fileState.claudeContribution 478: humanChars = 0 479: } else if (baseline) { 480: const diffSize = await getGitDiffSize(file) 481: humanChars = diffSize > 0 ? diffSize : stats.size 482: } else { 483: humanChars = stats.size 484: } 485: } catch { 486: return null 487: } 488: } 489: claudeChars = Math.max(0, claudeChars) 490: humanChars = Math.max(0, humanChars) 491: const total = claudeChars + humanChars 492: const percent = total > 0 ? Math.round((claudeChars / total) * 100) : 0 493: return { 494: type: 'file' as const, 495: file, 496: claudeChars, 497: humanChars, 498: percent, 499: surface: fileSurface, 500: } 501: }), 502: ) 503: for (const result of fileResults) { 504: if (!result) continue 505: if (result.type === 'generated') { 506: excludedGenerated.push(result.file) 507: continue 508: } 509: files[result.file] = { 510: claudeChars: result.claudeChars, 511: humanChars: result.humanChars, 512: percent: result.percent, 513: surface: result.surface, 514: } 515: totalClaudeChars += result.claudeChars 516: totalHumanChars += result.humanChars 517: surfaceCounts[result.surface] = 518: (surfaceCounts[result.surface] ?? 0) + result.claudeChars 519: } 520: const totalChars = totalClaudeChars + totalHumanChars 521: const claudePercent = 522: totalChars > 0 ? Math.round((totalClaudeChars / totalChars) * 100) : 0 523: const surfaceBreakdown: Record< 524: string, 525: { claudeChars: number; percent: number } 526: > = {} 527: for (const [surface, chars] of Object.entries(surfaceCounts)) { 528: const percent = totalChars > 0 ? Math.round((chars / totalChars) * 100) : 0 529: surfaceBreakdown[surface] = { claudeChars: chars, percent } 530: } 531: return { 532: version: 1, 533: summary: { 534: claudePercent, 535: claudeChars: totalClaudeChars, 536: humanChars: totalHumanChars, 537: surfaces: Array.from(surfaces), 538: }, 539: files, 540: surfaceBreakdown, 541: excludedGenerated, 542: sessions: [sessionId], 543: } 544: } 545: export async function getGitDiffSize(filePath: string): Promise<number> { 546: const cwd = getAttributionRepoRoot() 547: try { 548: const result = await execFileNoThrowWithCwd( 549: gitExe(), 550: ['diff', '--cached', '--stat', '--', filePath], 551: { cwd, timeout: 5000 }, 552: ) 553: if (result.code !== 0 || !result.stdout) { 554: return 0 555: } 556: const lines = result.stdout.split('\n').filter(Boolean) 557: let totalChanges = 0 558: for (const line of lines) { 559: if (line.includes('file changed') || line.includes('files changed')) { 560: const insertMatch = line.match(/(\d+) insertions?/) 561: const deleteMatch = line.match(/(\d+) deletions?/) 562: const insertions = insertMatch ? parseInt(insertMatch[1]!, 10) : 0 563: const deletions = deleteMatch ? parseInt(deleteMatch[1]!, 10) : 0 564: totalChanges += (insertions + deletions) * 40 565: } 566: } 567: return totalChanges 568: } catch { 569: return 0 570: } 571: } 572: export async function isFileDeleted(filePath: string): Promise<boolean> { 573: const cwd = getAttributionRepoRoot() 574: try { 575: const result = await execFileNoThrowWithCwd( 576: gitExe(), 577: ['diff', '--cached', '--name-status', '--', filePath], 578: { cwd, timeout: 5000 }, 579: ) 580: if (result.code === 0 && result.stdout) { 581: return result.stdout.trim().startsWith('D\t') 582: } 583: } catch { 584: } 585: return false 586: } 587: export async function getStagedFiles(): Promise<string[]> { 588: const cwd = getAttributionRepoRoot() 589: try { 590: const result = await execFileNoThrowWithCwd( 591: gitExe(), 592: ['diff', '--cached', '--name-only'], 593: { cwd, timeout: 5000 }, 594: ) 595: if (result.code === 0 && result.stdout) { 596: return result.stdout.split('\n').filter(Boolean) 597: } 598: } catch (error) { 599: logError(error as Error) 600: } 601: return [] 602: } 603: export async function isGitTransientState(): Promise<boolean> { 604: const gitDir = await resolveGitDir(getAttributionRepoRoot()) 605: if (!gitDir) return false 606: const indicators = [ 607: 'rebase-merge', 608: 'rebase-apply', 609: 'MERGE_HEAD', 610: 'CHERRY_PICK_HEAD', 611: 'BISECT_LOG', 612: ] 613: const results = await Promise.all( 614: indicators.map(async indicator => { 615: try { 616: await stat(join(gitDir, indicator)) 617: return true 618: } catch { 619: return false 620: } 621: }), 622: ) 623: return results.some(exists => exists) 624: } 625: export function stateToSnapshotMessage( 626: state: AttributionState, 627: messageId: UUID, 628: ): AttributionSnapshotMessage { 629: const fileStates: Record<string, FileAttributionState> = {} 630: for (const [path, fileState] of state.fileStates) { 631: fileStates[path] = fileState 632: } 633: return { 634: type: 'attribution-snapshot', 635: messageId, 636: surface: state.surface, 637: fileStates, 638: promptCount: state.promptCount, 639: promptCountAtLastCommit: state.promptCountAtLastCommit, 640: permissionPromptCount: state.permissionPromptCount, 641: permissionPromptCountAtLastCommit: state.permissionPromptCountAtLastCommit, 642: escapeCount: state.escapeCount, 643: escapeCountAtLastCommit: state.escapeCountAtLastCommit, 644: } 645: } 646: export function restoreAttributionStateFromSnapshots( 647: snapshots: AttributionSnapshotMessage[], 648: ): AttributionState { 649: const state = createEmptyAttributionState() 650: const lastSnapshot = snapshots[snapshots.length - 1] 651: if (!lastSnapshot) { 652: return state 653: } 654: state.surface = lastSnapshot.surface 655: for (const [path, fileState] of Object.entries(lastSnapshot.fileStates)) { 656: state.fileStates.set(path, fileState) 657: } 658: state.promptCount = lastSnapshot.promptCount ?? 0 659: state.promptCountAtLastCommit = lastSnapshot.promptCountAtLastCommit ?? 0 660: state.permissionPromptCount = lastSnapshot.permissionPromptCount ?? 0 661: state.permissionPromptCountAtLastCommit = 662: lastSnapshot.permissionPromptCountAtLastCommit ?? 0 663: state.escapeCount = lastSnapshot.escapeCount ?? 0 664: state.escapeCountAtLastCommit = lastSnapshot.escapeCountAtLastCommit ?? 0 665: return state 666: } 667: export function attributionRestoreStateFromLog( 668: attributionSnapshots: AttributionSnapshotMessage[], 669: onUpdateState: (newState: AttributionState) => void, 670: ): void { 671: const state = restoreAttributionStateFromSnapshots(attributionSnapshots) 672: onUpdateState(state) 673: } 674: export function incrementPromptCount( 675: attribution: AttributionState, 676: saveSnapshot: (snapshot: AttributionSnapshotMessage) => void, 677: ): AttributionState { 678: const newAttribution = { 679: ...attribution, 680: promptCount: attribution.promptCount + 1, 681: } 682: const snapshot = stateToSnapshotMessage(newAttribution, randomUUID()) 683: saveSnapshot(snapshot) 684: return newAttribution 685: }

File: src/utils/completionCache.ts

typescript 1: import chalk from 'chalk' 2: import { mkdir, readFile, writeFile } from 'fs/promises' 3: import { homedir } from 'os' 4: import { dirname, join } from 'path' 5: import { pathToFileURL } from 'url' 6: import { color } from '../components/design-system/color.js' 7: import { supportsHyperlinks } from '../ink/supports-hyperlinks.js' 8: import { logForDebugging } from './debug.js' 9: import { isENOENT } from './errors.js' 10: import { execFileNoThrow } from './execFileNoThrow.js' 11: import { logError } from './log.js' 12: import type { ThemeName } from './theme.js' 13: const EOL = '\n' 14: type ShellInfo = { 15: name: string 16: rcFile: string 17: cacheFile: string 18: completionLine: string 19: shellFlag: string 20: } 21: function detectShell(): ShellInfo | null { 22: const shell = process.env.SHELL || '' 23: const home = homedir() 24: const claudeDir = join(home, '.claude') 25: if (shell.endsWith('/zsh') || shell.endsWith('/zsh.exe')) { 26: const cacheFile = join(claudeDir, 'completion.zsh') 27: return { 28: name: 'zsh', 29: rcFile: join(home, '.zshrc'), 30: cacheFile, 31: completionLine: `[[ -f "${cacheFile}" ]] && source "${cacheFile}"`, 32: shellFlag: 'zsh', 33: } 34: } 35: if (shell.endsWith('/bash') || shell.endsWith('/bash.exe')) { 36: const cacheFile = join(claudeDir, 'completion.bash') 37: return { 38: name: 'bash', 39: rcFile: join(home, '.bashrc'), 40: cacheFile, 41: completionLine: `[ -f "${cacheFile}" ] && source "${cacheFile}"`, 42: shellFlag: 'bash', 43: } 44: } 45: if (shell.endsWith('/fish') || shell.endsWith('/fish.exe')) { 46: const xdg = process.env.XDG_CONFIG_HOME || join(home, '.config') 47: const cacheFile = join(claudeDir, 'completion.fish') 48: return { 49: name: 'fish', 50: rcFile: join(xdg, 'fish', 'config.fish'), 51: cacheFile, 52: completionLine: `[ -f "${cacheFile}" ] && source "${cacheFile}"`, 53: shellFlag: 'fish', 54: } 55: } 56: return null 57: } 58: function formatPathLink(filePath: string): string { 59: if (!supportsHyperlinks()) { 60: return filePath 61: } 62: const fileUrl = pathToFileURL(filePath).href 63: return `\x1b]8;;${fileUrl}\x07${filePath}\x1b]8;;\x07` 64: } 65: export async function setupShellCompletion(theme: ThemeName): Promise<string> { 66: const shell = detectShell() 67: if (!shell) { 68: return '' 69: } 70: // Ensure the cache directory exists 71: try { 72: await mkdir(dirname(shell.cacheFile), { recursive: true }) 73: } catch (e: unknown) { 74: logError(e) 75: return `${EOL}${color('warning', theme)(`Could not write ${shell.name} completion cache`)}${EOL}${chalk.dim(`Run manually: claude completion ${shell.shellFlag} > ${shell.cacheFile}`)}${EOL}` 76: } 77: // Generate the completion script by writing directly to the cache file. 78: // Using --output avoids piping through stdout where process.exit() can 79: // truncate output before the pipe buffer drains. 80: const claudeBin = process.argv[1] || 'claude' 81: const result = await execFileNoThrow(claudeBin, [ 82: 'completion', 83: shell.shellFlag, 84: '--output', 85: shell.cacheFile, 86: ]) 87: if (result.code !== 0) { 88: return `${EOL}${color('warning', theme)(`Could not generate ${shell.name} shell completions`)}${EOL}${chalk.dim(`Run manually: claude completion ${shell.shellFlag} > ${shell.cacheFile}`)}${EOL}` 89: } 90: // Check if rc file already sources completions 91: let existing = '' 92: try { 93: existing = await readFile(shell.rcFile, { encoding: 'utf-8' }) 94: if ( 95: existing.includes('claude completion') || 96: existing.includes(shell.cacheFile) 97: ) { 98: return `${EOL}${color('success', theme)(`Shell completions updated for ${shell.name}`)}${EOL}${chalk.dim(`See ${formatPathLink(shell.rcFile)}`)}${EOL}` 99: } 100: } catch (e: unknown) { 101: if (!isENOENT(e)) { 102: logError(e) 103: return `${EOL}${color('warning', theme)(`Could not install ${shell.name} shell completions`)}${EOL}${chalk.dim(`Add this to ${formatPathLink(shell.rcFile)}:`)}${EOL}${chalk.dim(shell.completionLine)}${EOL}` 104: } 105: } 106: // Append source line to rc file 107: try { 108: const configDir = dirname(shell.rcFile) 109: await mkdir(configDir, { recursive: true }) 110: const separator = existing && !existing.endsWith('\n') ? '\n' : '' 111: const content = `${existing}${separator}\n# Claude Code shell completions\n${shell.completionLine}\n` 112: await writeFile(shell.rcFile, content, { encoding: 'utf-8' }) 113: return `${EOL}${color('success', theme)(`Installed ${shell.name} shell completions`)}${EOL}${chalk.dim(`Added to ${formatPathLink(shell.rcFile)}`)}${EOL}${chalk.dim(`Run: source ${shell.rcFile}`)}${EOL}` 114: } catch (error) { 115: logError(error) 116: return `${EOL}${color('warning', theme)(`Could not install ${shell.name} shell completions`)}${EOL}${chalk.dim(`Add this to ${formatPathLink(shell.rcFile)}:`)}${EOL}${chalk.dim(shell.completionLine)}${EOL}` 117: } 118: } 119: /** 120: * Regenerate cached shell completion scripts in ~/.claude/. 121: * Called after `claude update` so completions stay in sync with the new binary. 122: */ 123: export async function regenerateCompletionCache(): Promise<void> { 124: const shell = detectShell() 125: if (!shell) { 126: return 127: } 128: logForDebugging(`update: Regenerating ${shell.name} completion cache`) 129: const claudeBin = process.argv[1] || 'claude' 130: const result = await execFileNoThrow(claudeBin, [ 131: 'completion', 132: shell.shellFlag, 133: '--output', 134: shell.cacheFile, 135: ]) 136: if (result.code !== 0) { 137: logForDebugging( 138: `update: Failed to regenerate ${shell.name} completion cache`, 139: ) 140: return 141: } 142: logForDebugging( 143: `update: Regenerated ${shell.name} completion cache at ${shell.cacheFile}`, 144: ) 145: }

File: src/utils/concurrentSessions.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { chmod, mkdir, readdir, readFile, unlink, writeFile } from 'fs/promises' 3: import { join } from 'path' 4: import { 5: getOriginalCwd, 6: getSessionId, 7: onSessionSwitch, 8: } from '../bootstrap/state.js' 9: import { registerCleanup } from './cleanupRegistry.js' 10: import { logForDebugging } from './debug.js' 11: import { getClaudeConfigHomeDir } from './envUtils.js' 12: import { errorMessage, isFsInaccessible } from './errors.js' 13: import { isProcessRunning } from './genericProcessUtils.js' 14: import { getPlatform } from './platform.js' 15: import { jsonParse, jsonStringify } from './slowOperations.js' 16: import { getAgentId } from './teammate.js' 17: export type SessionKind = 'interactive' | 'bg' | 'daemon' | 'daemon-worker' 18: export type SessionStatus = 'busy' | 'idle' | 'waiting' 19: function getSessionsDir(): string { 20: return join(getClaudeConfigHomeDir(), 'sessions') 21: } 22: function envSessionKind(): SessionKind | undefined { 23: if (feature('BG_SESSIONS')) { 24: const k = process.env.CLAUDE_CODE_SESSION_KIND 25: if (k === 'bg' || k === 'daemon' || k === 'daemon-worker') return k 26: } 27: return undefined 28: } 29: export function isBgSession(): boolean { 30: return envSessionKind() === 'bg' 31: } 32: export async function registerSession(): Promise<boolean> { 33: if (getAgentId() != null) return false 34: const kind: SessionKind = envSessionKind() ?? 'interactive' 35: const dir = getSessionsDir() 36: const pidFile = join(dir, `${process.pid}.json`) 37: registerCleanup(async () => { 38: try { 39: await unlink(pidFile) 40: } catch { 41: } 42: }) 43: try { 44: await mkdir(dir, { recursive: true, mode: 0o700 }) 45: await chmod(dir, 0o700) 46: await writeFile( 47: pidFile, 48: jsonStringify({ 49: pid: process.pid, 50: sessionId: getSessionId(), 51: cwd: getOriginalCwd(), 52: startedAt: Date.now(), 53: kind, 54: entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, 55: ...(feature('UDS_INBOX') 56: ? { messagingSocketPath: process.env.CLAUDE_CODE_MESSAGING_SOCKET } 57: : {}), 58: ...(feature('BG_SESSIONS') 59: ? { 60: name: process.env.CLAUDE_CODE_SESSION_NAME, 61: logPath: process.env.CLAUDE_CODE_SESSION_LOG, 62: agent: process.env.CLAUDE_CODE_AGENT, 63: } 64: : {}), 65: }), 66: ) 67: onSessionSwitch(id => { 68: void updatePidFile({ sessionId: id }) 69: }) 70: return true 71: } catch (e) { 72: logForDebugging(`[concurrentSessions] register failed: ${errorMessage(e)}`) 73: return false 74: } 75: } 76: async function updatePidFile(patch: Record<string, unknown>): Promise<void> { 77: const pidFile = join(getSessionsDir(), `${process.pid}.json`) 78: try { 79: const data = jsonParse(await readFile(pidFile, 'utf8')) as Record< 80: string, 81: unknown 82: > 83: await writeFile(pidFile, jsonStringify({ ...data, ...patch })) 84: } catch (e) { 85: logForDebugging( 86: `[concurrentSessions] updatePidFile failed: ${errorMessage(e)}`, 87: ) 88: } 89: } 90: export async function updateSessionName( 91: name: string | undefined, 92: ): Promise<void> { 93: if (!name) return 94: await updatePidFile({ name }) 95: } 96: export async function updateSessionBridgeId( 97: bridgeSessionId: string | null, 98: ): Promise<void> { 99: await updatePidFile({ bridgeSessionId }) 100: } 101: export async function updateSessionActivity(patch: { 102: status?: SessionStatus 103: waitingFor?: string 104: }): Promise<void> { 105: if (!feature('BG_SESSIONS')) return 106: await updatePidFile({ ...patch, updatedAt: Date.now() }) 107: } 108: export async function countConcurrentSessions(): Promise<number> { 109: const dir = getSessionsDir() 110: let files: string[] 111: try { 112: files = await readdir(dir) 113: } catch (e) { 114: if (!isFsInaccessible(e)) { 115: logForDebugging(`[concurrentSessions] readdir failed: ${errorMessage(e)}`) 116: } 117: return 0 118: } 119: let count = 0 120: for (const file of files) { 121: if (!/^\d+\.json$/.test(file)) continue 122: const pid = parseInt(file.slice(0, -5), 10) 123: if (pid === process.pid) { 124: count++ 125: continue 126: } 127: if (isProcessRunning(pid)) { 128: count++ 129: } else if (getPlatform() !== 'wsl') { 130: void unlink(join(dir, file)).catch(() => {}) 131: } 132: } 133: return count 134: }

File: src/utils/config.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { randomBytes } from 'crypto' 3: import { unwatchFile, watchFile } from 'fs' 4: import memoize from 'lodash-es/memoize.js' 5: import pickBy from 'lodash-es/pickBy.js' 6: import { basename, dirname, join, resolve } from 'path' 7: import { getOriginalCwd, getSessionTrustAccepted } from '../bootstrap/state.js' 8: import { getAutoMemEntrypoint } from '../memdir/paths.js' 9: import { logEvent } from '../services/analytics/index.js' 10: import type { McpServerConfig } from '../services/mcp/types.js' 11: import type { 12: BillingType, 13: ReferralEligibilityResponse, 14: } from '../services/oauth/types.js' 15: import { getCwd } from '../utils/cwd.js' 16: import { registerCleanup } from './cleanupRegistry.js' 17: import { logForDebugging } from './debug.js' 18: import { logForDiagnosticsNoPII } from './diagLogs.js' 19: import { getGlobalClaudeFile } from './env.js' 20: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 21: import { ConfigParseError, getErrnoCode } from './errors.js' 22: import { writeFileSyncAndFlush_DEPRECATED } from './file.js' 23: import { getFsImplementation } from './fsOperations.js' 24: import { findCanonicalGitRoot } from './git.js' 25: import { safeParseJSON } from './json.js' 26: import { stripBOM } from './jsonRead.js' 27: import * as lockfile from './lockfile.js' 28: import { logError } from './log.js' 29: import type { MemoryType } from './memory/types.js' 30: import { normalizePathForConfigKey } from './path.js' 31: import { getEssentialTrafficOnlyReason } from './privacyLevel.js' 32: import { getManagedFilePath } from './settings/managedPath.js' 33: import type { ThemeSetting } from './theme.js' 34: const teamMemPaths = feature('TEAMMEM') 35: ? (require('../memdir/teamMemPaths.js') as typeof import('../memdir/teamMemPaths.js')) 36: : null 37: const ccrAutoConnect = feature('CCR_AUTO_CONNECT') 38: ? (require('../bridge/bridgeEnabled.js') as typeof import('../bridge/bridgeEnabled.js')) 39: : null 40: import type { ImageDimensions } from './imageResizer.js' 41: import type { ModelOption } from './model/modelOptions.js' 42: import { jsonParse, jsonStringify } from './slowOperations.js' 43: let insideGetConfig = false 44: export type PastedContent = { 45: id: number 46: type: 'text' | 'image' 47: content: string 48: mediaType?: string 49: filename?: string 50: dimensions?: ImageDimensions 51: sourcePath?: string 52: } 53: export interface SerializedStructuredHistoryEntry { 54: display: string 55: pastedContents?: Record<number, PastedContent> 56: pastedText?: string 57: } 58: export interface HistoryEntry { 59: display: string 60: pastedContents: Record<number, PastedContent> 61: } 62: export type ReleaseChannel = 'stable' | 'latest' 63: export type ProjectConfig = { 64: allowedTools: string[] 65: mcpContextUris: string[] 66: mcpServers?: Record<string, McpServerConfig> 67: lastAPIDuration?: number 68: lastAPIDurationWithoutRetries?: number 69: lastToolDuration?: number 70: lastCost?: number 71: lastDuration?: number 72: lastLinesAdded?: number 73: lastLinesRemoved?: number 74: lastTotalInputTokens?: number 75: lastTotalOutputTokens?: number 76: lastTotalCacheCreationInputTokens?: number 77: lastTotalCacheReadInputTokens?: number 78: lastTotalWebSearchRequests?: number 79: lastFpsAverage?: number 80: lastFpsLow1Pct?: number 81: lastSessionId?: string 82: lastModelUsage?: Record< 83: string, 84: { 85: inputTokens: number 86: outputTokens: number 87: cacheReadInputTokens: number 88: cacheCreationInputTokens: number 89: webSearchRequests: number 90: costUSD: number 91: } 92: > 93: lastSessionMetrics?: Record<string, number> 94: exampleFiles?: string[] 95: exampleFilesGeneratedAt?: number 96: hasTrustDialogAccepted?: boolean 97: hasCompletedProjectOnboarding?: boolean 98: projectOnboardingSeenCount: number 99: hasClaudeMdExternalIncludesApproved?: boolean 100: hasClaudeMdExternalIncludesWarningShown?: boolean 101: enabledMcpjsonServers?: string[] 102: disabledMcpjsonServers?: string[] 103: enableAllProjectMcpServers?: boolean 104: disabledMcpServers?: string[] 105: enabledMcpServers?: string[] 106: activeWorktreeSession?: { 107: originalCwd: string 108: worktreePath: string 109: worktreeName: string 110: originalBranch?: string 111: sessionId: string 112: hookBased?: boolean 113: } 114: remoteControlSpawnMode?: 'same-dir' | 'worktree' 115: } 116: const DEFAULT_PROJECT_CONFIG: ProjectConfig = { 117: allowedTools: [], 118: mcpContextUris: [], 119: mcpServers: {}, 120: enabledMcpjsonServers: [], 121: disabledMcpjsonServers: [], 122: hasTrustDialogAccepted: false, 123: projectOnboardingSeenCount: 0, 124: hasClaudeMdExternalIncludesApproved: false, 125: hasClaudeMdExternalIncludesWarningShown: false, 126: } 127: export type InstallMethod = 'local' | 'native' | 'global' | 'unknown' 128: export { 129: EDITOR_MODES, 130: NOTIFICATION_CHANNELS, 131: } from './configConstants.js' 132: import type { EDITOR_MODES, NOTIFICATION_CHANNELS } from './configConstants.js' 133: export type NotificationChannel = (typeof NOTIFICATION_CHANNELS)[number] 134: export type AccountInfo = { 135: accountUuid: string 136: emailAddress: string 137: organizationUuid?: string 138: organizationName?: string | null 139: organizationRole?: string | null 140: workspaceRole?: string | null 141: displayName?: string 142: hasExtraUsageEnabled?: boolean 143: billingType?: BillingType | null 144: accountCreatedAt?: string 145: subscriptionCreatedAt?: string 146: } 147: export type EditorMode = 'emacs' | (typeof EDITOR_MODES)[number] 148: export type DiffTool = 'terminal' | 'auto' 149: export type OutputStyle = string 150: export type GlobalConfig = { 151: apiKeyHelper?: string 152: projects?: Record<string, ProjectConfig> 153: numStartups: number 154: installMethod?: InstallMethod 155: autoUpdates?: boolean 156: autoUpdatesProtectedForNative?: boolean 157: doctorShownAtSession?: number 158: userID?: string 159: theme: ThemeSetting 160: hasCompletedOnboarding?: boolean 161: lastOnboardingVersion?: string 162: lastReleaseNotesSeen?: string 163: changelogLastFetched?: number 164: cachedChangelog?: string 165: mcpServers?: Record<string, McpServerConfig> 166: claudeAiMcpEverConnected?: string[] 167: preferredNotifChannel: NotificationChannel 168: customNotifyCommand?: string 169: verbose: boolean 170: customApiKeyResponses?: { 171: approved?: string[] 172: rejected?: string[] 173: } 174: primaryApiKey?: string 175: hasAcknowledgedCostThreshold?: boolean 176: hasSeenUndercoverAutoNotice?: boolean 177: hasSeenUltraplanTerms?: boolean 178: hasResetAutoModeOptInForDefaultOffer?: boolean 179: oauthAccount?: AccountInfo 180: iterm2KeyBindingInstalled?: boolean 181: editorMode?: EditorMode 182: bypassPermissionsModeAccepted?: boolean 183: hasUsedBackslashReturn?: boolean 184: autoCompactEnabled: boolean 185: showTurnDuration: boolean 186: env: { [key: string]: string } 187: hasSeenTasksHint?: boolean 188: hasUsedStash?: boolean 189: hasUsedBackgroundTask?: boolean 190: queuedCommandUpHintCount?: number 191: diffTool?: DiffTool 192: iterm2SetupInProgress?: boolean 193: iterm2BackupPath?: string 194: appleTerminalBackupPath?: string 195: appleTerminalSetupInProgress?: boolean 196: shiftEnterKeyBindingInstalled?: boolean 197: optionAsMetaKeyInstalled?: boolean 198: autoConnectIde?: boolean 199: autoInstallIdeExtension?: boolean 200: hasIdeOnboardingBeenShown?: Record<string, boolean> 201: ideHintShownCount?: number 202: hasIdeAutoConnectDialogBeenShown?: boolean 203: tipsHistory: { 204: [tipId: string]: number 205: } 206: companion?: import('../buddy/types.js').StoredCompanion 207: companionMuted?: boolean 208: feedbackSurveyState?: { 209: lastShownTime?: number 210: } 211: transcriptShareDismissed?: boolean 212: memoryUsageCount: number 213: hasShownS1MWelcomeV2?: Record<string, boolean> 214: s1mAccessCache?: Record< 215: string, 216: { hasAccess: boolean; hasAccessNotAsDefault?: boolean; timestamp: number } 217: > 218: s1mNonSubscriberAccessCache?: Record< 219: string, 220: { hasAccess: boolean; hasAccessNotAsDefault?: boolean; timestamp: number } 221: > 222: passesEligibilityCache?: Record< 223: string, 224: ReferralEligibilityResponse & { timestamp: number } 225: > 226: groveConfigCache?: Record< 227: string, 228: { grove_enabled: boolean; timestamp: number } 229: > 230: passesUpsellSeenCount?: number 231: hasVisitedPasses?: boolean 232: passesLastSeenRemaining?: number 233: overageCreditGrantCache?: Record< 234: string, 235: { 236: info: { 237: available: boolean 238: eligible: boolean 239: granted: boolean 240: amount_minor_units: number | null 241: currency: string | null 242: } 243: timestamp: number 244: } 245: > 246: overageCreditUpsellSeenCount?: number 247: hasVisitedExtraUsage?: boolean 248: voiceNoticeSeenCount?: number 249: voiceLangHintShownCount?: number 250: voiceLangHintLastLanguage?: string 251: voiceFooterHintSeenCount?: number 252: opus1mMergeNoticeSeenCount?: number 253: experimentNoticesSeenCount?: Record<string, number> 254: hasShownOpusPlanWelcome?: Record<string, boolean> 255: promptQueueUseCount: number 256: btwUseCount: number 257: lastPlanModeUse?: number 258: subscriptionNoticeCount?: number 259: hasAvailableSubscription?: boolean 260: subscriptionUpsellShownCount?: number 261: recommendedSubscription?: string 262: todoFeatureEnabled: boolean 263: showExpandedTodos?: boolean 264: showSpinnerTree?: boolean 265: firstStartTime?: string 266: messageIdleNotifThresholdMs: number 267: githubActionSetupCount?: number 268: slackAppInstallCount?: number 269: fileCheckpointingEnabled: boolean 270: terminalProgressBarEnabled: boolean 271: showStatusInTerminalTab?: boolean 272: taskCompleteNotifEnabled?: boolean 273: inputNeededNotifEnabled?: boolean 274: agentPushNotifEnabled?: boolean 275: claudeCodeFirstTokenDate?: string 276: modelSwitchCalloutDismissed?: boolean 277: modelSwitchCalloutLastShown?: number 278: modelSwitchCalloutVersion?: string 279: effortCalloutDismissed?: boolean 280: effortCalloutV2Dismissed?: boolean 281: remoteDialogSeen?: boolean 282: bridgeOauthDeadExpiresAt?: number 283: bridgeOauthDeadFailCount?: number 284: desktopUpsellSeenCount?: number 285: desktopUpsellDismissed?: boolean 286: idleReturnDismissed?: boolean 287: opusProMigrationComplete?: boolean 288: opusProMigrationTimestamp?: number 289: sonnet1m45MigrationComplete?: boolean 290: legacyOpusMigrationTimestamp?: number 291: sonnet45To46MigrationTimestamp?: number 292: cachedStatsigGates: { 293: [gateName: string]: boolean 294: } 295: cachedDynamicConfigs?: { [configName: string]: unknown } 296: cachedGrowthBookFeatures?: { [featureName: string]: unknown } 297: growthBookOverrides?: { [featureName: string]: unknown } 298: lastShownEmergencyTip?: string 299: respectGitignore: boolean 300: copyFullResponse: boolean 301: copyOnSelect?: boolean 302: githubRepoPaths?: Record<string, string[]> 303: deepLinkTerminal?: string 304: iterm2It2SetupComplete?: boolean 305: preferTmuxOverIterm2?: boolean 306: skillUsage?: Record<string, { usageCount: number; lastUsedAt: number }> 307: officialMarketplaceAutoInstallAttempted?: boolean 308: officialMarketplaceAutoInstalled?: boolean 309: officialMarketplaceAutoInstallFailReason?: 310: | 'policy_blocked' 311: | 'git_unavailable' 312: | 'gcs_unavailable' 313: | 'unknown' 314: officialMarketplaceAutoInstallRetryCount?: number 315: officialMarketplaceAutoInstallLastAttemptTime?: number 316: officialMarketplaceAutoInstallNextRetryTime?: number 317: hasCompletedClaudeInChromeOnboarding?: boolean 318: claudeInChromeDefaultEnabled?: boolean 319: cachedChromeExtensionInstalled?: boolean 320: chromeExtension?: { 321: pairedDeviceId?: string 322: pairedDeviceName?: string 323: } 324: lspRecommendationDisabled?: boolean 325: lspRecommendationNeverPlugins?: string[] 326: lspRecommendationIgnoredCount?: number 327: claudeCodeHints?: { 328: plugin?: string[] 329: disabled?: boolean 330: } 331: permissionExplainerEnabled?: boolean 332: teammateMode?: 'auto' | 'tmux' | 'in-process' 333: teammateDefaultModel?: string | null 334: prStatusFooterEnabled?: boolean 335: tungstenPanelVisible?: boolean 336: penguinModeOrgEnabled?: boolean 337: startupPrefetchedAt?: number 338: remoteControlAtStartup?: boolean 339: cachedExtraUsageDisabledReason?: string | null 340: autoPermissionsNotificationCount?: number 341: speculationEnabled?: boolean 342: clientDataCache?: Record<string, unknown> | null 343: additionalModelOptionsCache?: ModelOption[] 344: metricsStatusCache?: { 345: enabled: boolean 346: timestamp: number 347: } 348: migrationVersion?: number 349: } 350: function createDefaultGlobalConfig(): GlobalConfig { 351: return { 352: numStartups: 0, 353: installMethod: undefined, 354: autoUpdates: undefined, 355: theme: 'dark', 356: preferredNotifChannel: 'auto', 357: verbose: false, 358: editorMode: 'normal', 359: autoCompactEnabled: true, 360: showTurnDuration: true, 361: hasSeenTasksHint: false, 362: hasUsedStash: false, 363: hasUsedBackgroundTask: false, 364: queuedCommandUpHintCount: 0, 365: diffTool: 'auto', 366: customApiKeyResponses: { 367: approved: [], 368: rejected: [], 369: }, 370: env: {}, 371: tipsHistory: {}, 372: memoryUsageCount: 0, 373: promptQueueUseCount: 0, 374: btwUseCount: 0, 375: todoFeatureEnabled: true, 376: showExpandedTodos: false, 377: messageIdleNotifThresholdMs: 60000, 378: autoConnectIde: false, 379: autoInstallIdeExtension: true, 380: fileCheckpointingEnabled: true, 381: terminalProgressBarEnabled: true, 382: cachedStatsigGates: {}, 383: cachedDynamicConfigs: {}, 384: cachedGrowthBookFeatures: {}, 385: respectGitignore: true, 386: copyFullResponse: false, 387: } 388: } 389: export const DEFAULT_GLOBAL_CONFIG: GlobalConfig = createDefaultGlobalConfig() 390: export const GLOBAL_CONFIG_KEYS = [ 391: 'apiKeyHelper', 392: 'installMethod', 393: 'autoUpdates', 394: 'autoUpdatesProtectedForNative', 395: 'theme', 396: 'verbose', 397: 'preferredNotifChannel', 398: 'shiftEnterKeyBindingInstalled', 399: 'editorMode', 400: 'hasUsedBackslashReturn', 401: 'autoCompactEnabled', 402: 'showTurnDuration', 403: 'diffTool', 404: 'env', 405: 'tipsHistory', 406: 'todoFeatureEnabled', 407: 'showExpandedTodos', 408: 'messageIdleNotifThresholdMs', 409: 'autoConnectIde', 410: 'autoInstallIdeExtension', 411: 'fileCheckpointingEnabled', 412: 'terminalProgressBarEnabled', 413: 'showStatusInTerminalTab', 414: 'taskCompleteNotifEnabled', 415: 'inputNeededNotifEnabled', 416: 'agentPushNotifEnabled', 417: 'respectGitignore', 418: 'claudeInChromeDefaultEnabled', 419: 'hasCompletedClaudeInChromeOnboarding', 420: 'lspRecommendationDisabled', 421: 'lspRecommendationNeverPlugins', 422: 'lspRecommendationIgnoredCount', 423: 'copyFullResponse', 424: 'copyOnSelect', 425: 'permissionExplainerEnabled', 426: 'prStatusFooterEnabled', 427: 'remoteControlAtStartup', 428: 'remoteDialogSeen', 429: ] as const 430: export type GlobalConfigKey = (typeof GLOBAL_CONFIG_KEYS)[number] 431: export function isGlobalConfigKey(key: string): key is GlobalConfigKey { 432: return GLOBAL_CONFIG_KEYS.includes(key as GlobalConfigKey) 433: } 434: export const PROJECT_CONFIG_KEYS = [ 435: 'allowedTools', 436: 'hasTrustDialogAccepted', 437: 'hasCompletedProjectOnboarding', 438: ] as const 439: export type ProjectConfigKey = (typeof PROJECT_CONFIG_KEYS)[number] 440: let _trustAccepted = false 441: export function resetTrustDialogAcceptedCacheForTesting(): void { 442: _trustAccepted = false 443: } 444: export function checkHasTrustDialogAccepted(): boolean { 445: return (_trustAccepted ||= computeTrustDialogAccepted()) 446: } 447: function computeTrustDialogAccepted(): boolean { 448: if (getSessionTrustAccepted()) { 449: return true 450: } 451: const config = getGlobalConfig() 452: const projectPath = getProjectPathForConfig() 453: const projectConfig = config.projects?.[projectPath] 454: if (projectConfig?.hasTrustDialogAccepted) { 455: return true 456: } 457: let currentPath = normalizePathForConfigKey(getCwd()) 458: while (true) { 459: const pathConfig = config.projects?.[currentPath] 460: if (pathConfig?.hasTrustDialogAccepted) { 461: return true 462: } 463: const parentPath = normalizePathForConfigKey(resolve(currentPath, '..')) 464: if (parentPath === currentPath) { 465: break 466: } 467: currentPath = parentPath 468: } 469: return false 470: } 471: export function isPathTrusted(dir: string): boolean { 472: const config = getGlobalConfig() 473: let currentPath = normalizePathForConfigKey(resolve(dir)) 474: while (true) { 475: if (config.projects?.[currentPath]?.hasTrustDialogAccepted) return true 476: const parentPath = normalizePathForConfigKey(resolve(currentPath, '..')) 477: if (parentPath === currentPath) return false 478: currentPath = parentPath 479: } 480: } 481: const TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = { 482: ...DEFAULT_GLOBAL_CONFIG, 483: autoUpdates: false, 484: } 485: const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = { 486: ...DEFAULT_PROJECT_CONFIG, 487: } 488: export function isProjectConfigKey(key: string): key is ProjectConfigKey { 489: return PROJECT_CONFIG_KEYS.includes(key as ProjectConfigKey) 490: } 491: function wouldLoseAuthState(fresh: { 492: oauthAccount?: unknown 493: hasCompletedOnboarding?: boolean 494: }): boolean { 495: const cached = globalConfigCache.config 496: if (!cached) return false 497: const lostOauth = 498: cached.oauthAccount !== undefined && fresh.oauthAccount === undefined 499: const lostOnboarding = 500: cached.hasCompletedOnboarding === true && 501: fresh.hasCompletedOnboarding !== true 502: return lostOauth || lostOnboarding 503: } 504: export function saveGlobalConfig( 505: updater: (currentConfig: GlobalConfig) => GlobalConfig, 506: ): void { 507: if (process.env.NODE_ENV === 'test') { 508: const config = updater(TEST_GLOBAL_CONFIG_FOR_TESTING) 509: if (config === TEST_GLOBAL_CONFIG_FOR_TESTING) { 510: return 511: } 512: Object.assign(TEST_GLOBAL_CONFIG_FOR_TESTING, config) 513: return 514: } 515: let written: GlobalConfig | null = null 516: try { 517: const didWrite = saveConfigWithLock( 518: getGlobalClaudeFile(), 519: createDefaultGlobalConfig, 520: current => { 521: const config = updater(current) 522: if (config === current) { 523: return current 524: } 525: written = { 526: ...config, 527: projects: removeProjectHistory(current.projects), 528: } 529: return written 530: }, 531: ) 532: if (didWrite && written) { 533: writeThroughGlobalConfigCache(written) 534: } 535: } catch (error) { 536: logForDebugging(`Failed to save config with lock: ${error}`, { 537: level: 'error', 538: }) 539: const currentConfig = getConfig( 540: getGlobalClaudeFile(), 541: createDefaultGlobalConfig, 542: ) 543: if (wouldLoseAuthState(currentConfig)) { 544: logForDebugging( 545: 'saveGlobalConfig fallback: re-read config is missing auth that cache has; refusing to write. See GH #3117.', 546: { level: 'error' }, 547: ) 548: logEvent('tengu_config_auth_loss_prevented', {}) 549: return 550: } 551: const config = updater(currentConfig) 552: if (config === currentConfig) { 553: return 554: } 555: written = { 556: ...config, 557: projects: removeProjectHistory(currentConfig.projects), 558: } 559: saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG) 560: writeThroughGlobalConfigCache(written) 561: } 562: } 563: let globalConfigCache: { config: GlobalConfig | null; mtime: number } = { 564: config: null, 565: mtime: 0, 566: } 567: let lastReadFileStats: { mtime: number; size: number } | null = null 568: let configCacheHits = 0 569: let configCacheMisses = 0 570: let globalConfigWriteCount = 0 571: export function getGlobalConfigWriteCount(): number { 572: return globalConfigWriteCount 573: } 574: export const CONFIG_WRITE_DISPLAY_THRESHOLD = 20 575: function reportConfigCacheStats(): void { 576: const total = configCacheHits + configCacheMisses 577: if (total > 0) { 578: logEvent('tengu_config_cache_stats', { 579: cache_hits: configCacheHits, 580: cache_misses: configCacheMisses, 581: hit_rate: configCacheHits / total, 582: }) 583: } 584: configCacheHits = 0 585: configCacheMisses = 0 586: } 587: registerCleanup(async () => { 588: reportConfigCacheStats() 589: }) 590: function migrateConfigFields(config: GlobalConfig): GlobalConfig { 591: if (config.installMethod !== undefined) { 592: return config 593: } 594: const legacy = config as GlobalConfig & { 595: autoUpdaterStatus?: 596: | 'migrated' 597: | 'installed' 598: | 'disabled' 599: | 'enabled' 600: | 'no_permissions' 601: | 'not_configured' 602: } 603: let installMethod: InstallMethod = 'unknown' 604: let autoUpdates = config.autoUpdates ?? true 605: switch (legacy.autoUpdaterStatus) { 606: case 'migrated': 607: installMethod = 'local' 608: break 609: case 'installed': 610: installMethod = 'native' 611: break 612: case 'disabled': 613: autoUpdates = false 614: break 615: case 'enabled': 616: case 'no_permissions': 617: case 'not_configured': 618: installMethod = 'global' 619: break 620: case undefined: 621: break 622: } 623: return { 624: ...config, 625: installMethod, 626: autoUpdates, 627: } 628: } 629: function removeProjectHistory( 630: projects: Record<string, ProjectConfig> | undefined, 631: ): Record<string, ProjectConfig> | undefined { 632: if (!projects) { 633: return projects 634: } 635: const cleanedProjects: Record<string, ProjectConfig> = {} 636: let needsCleaning = false 637: for (const [path, projectConfig] of Object.entries(projects)) { 638: const legacy = projectConfig as ProjectConfig & { history?: unknown } 639: if (legacy.history !== undefined) { 640: needsCleaning = true 641: const { history, ...cleanedConfig } = legacy 642: cleanedProjects[path] = cleanedConfig 643: } else { 644: cleanedProjects[path] = projectConfig 645: } 646: } 647: return needsCleaning ? cleanedProjects : projects 648: } 649: const CONFIG_FRESHNESS_POLL_MS = 1000 650: let freshnessWatcherStarted = false 651: function startGlobalConfigFreshnessWatcher(): void { 652: if (freshnessWatcherStarted || process.env.NODE_ENV === 'test') return 653: freshnessWatcherStarted = true 654: const file = getGlobalClaudeFile() 655: watchFile( 656: file, 657: { interval: CONFIG_FRESHNESS_POLL_MS, persistent: false }, 658: curr => { 659: if (curr.mtimeMs <= globalConfigCache.mtime) return 660: void getFsImplementation() 661: .readFile(file, { encoding: 'utf-8' }) 662: .then(content => { 663: if (curr.mtimeMs <= globalConfigCache.mtime) return 664: const parsed = safeParseJSON(stripBOM(content)) 665: if (parsed === null || typeof parsed !== 'object') return 666: globalConfigCache = { 667: config: migrateConfigFields({ 668: ...createDefaultGlobalConfig(), 669: ...(parsed as Partial<GlobalConfig>), 670: }), 671: mtime: curr.mtimeMs, 672: } 673: lastReadFileStats = { mtime: curr.mtimeMs, size: curr.size } 674: }) 675: .catch(() => {}) 676: }, 677: ) 678: registerCleanup(async () => { 679: unwatchFile(file) 680: freshnessWatcherStarted = false 681: }) 682: } 683: function writeThroughGlobalConfigCache(config: GlobalConfig): void { 684: globalConfigCache = { config, mtime: Date.now() } 685: lastReadFileStats = null 686: } 687: export function getGlobalConfig(): GlobalConfig { 688: if (process.env.NODE_ENV === 'test') { 689: return TEST_GLOBAL_CONFIG_FOR_TESTING 690: } 691: if (globalConfigCache.config) { 692: configCacheHits++ 693: return globalConfigCache.config 694: } 695: configCacheMisses++ 696: try { 697: let stats: { mtimeMs: number; size: number } | null = null 698: try { 699: stats = getFsImplementation().statSync(getGlobalClaudeFile()) 700: } catch { 701: } 702: const config = migrateConfigFields( 703: getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig), 704: ) 705: globalConfigCache = { 706: config, 707: mtime: stats?.mtimeMs ?? Date.now(), 708: } 709: lastReadFileStats = stats 710: ? { mtime: stats.mtimeMs, size: stats.size } 711: : null 712: startGlobalConfigFreshnessWatcher() 713: return config 714: } catch { 715: return migrateConfigFields( 716: getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig), 717: ) 718: } 719: } 720: export function getRemoteControlAtStartup(): boolean { 721: const explicit = getGlobalConfig().remoteControlAtStartup 722: if (explicit !== undefined) return explicit 723: if (feature('CCR_AUTO_CONNECT')) { 724: if (ccrAutoConnect?.getCcrAutoConnectDefault()) return true 725: } 726: return false 727: } 728: export function getCustomApiKeyStatus( 729: truncatedApiKey: string, 730: ): 'approved' | 'rejected' | 'new' { 731: const config = getGlobalConfig() 732: if (config.customApiKeyResponses?.approved?.includes(truncatedApiKey)) { 733: return 'approved' 734: } 735: if (config.customApiKeyResponses?.rejected?.includes(truncatedApiKey)) { 736: return 'rejected' 737: } 738: return 'new' 739: } 740: function saveConfig<A extends object>( 741: file: string, 742: config: A, 743: defaultConfig: A, 744: ): void { 745: const dir = dirname(file) 746: const fs = getFsImplementation() 747: fs.mkdirSync(dir) 748: const filteredConfig = pickBy( 749: config, 750: (value, key) => 751: jsonStringify(value) !== jsonStringify(defaultConfig[key as keyof A]), 752: ) 753: writeFileSyncAndFlush_DEPRECATED( 754: file, 755: jsonStringify(filteredConfig, null, 2), 756: { 757: encoding: 'utf-8', 758: mode: 0o600, 759: }, 760: ) 761: if (file === getGlobalClaudeFile()) { 762: globalConfigWriteCount++ 763: } 764: } 765: function saveConfigWithLock<A extends object>( 766: file: string, 767: createDefault: () => A, 768: mergeFn: (current: A) => A, 769: ): boolean { 770: const defaultConfig = createDefault() 771: const dir = dirname(file) 772: const fs = getFsImplementation() 773: fs.mkdirSync(dir) 774: let release 775: try { 776: const lockFilePath = `${file}.lock` 777: const startTime = Date.now() 778: release = lockfile.lockSync(file, { 779: lockfilePath: lockFilePath, 780: onCompromised: err => { 781: logForDebugging(`Config lock compromised: ${err}`, { level: 'error' }) 782: }, 783: }) 784: const lockTime = Date.now() - startTime 785: if (lockTime > 100) { 786: logForDebugging( 787: 'Lock acquisition took longer than expected - another Claude instance may be running', 788: ) 789: logEvent('tengu_config_lock_contention', { 790: lock_time_ms: lockTime, 791: }) 792: } 793: if (lastReadFileStats && file === getGlobalClaudeFile()) { 794: try { 795: const currentStats = fs.statSync(file) 796: if ( 797: currentStats.mtimeMs !== lastReadFileStats.mtime || 798: currentStats.size !== lastReadFileStats.size 799: ) { 800: logEvent('tengu_config_stale_write', { 801: read_mtime: lastReadFileStats.mtime, 802: write_mtime: currentStats.mtimeMs, 803: read_size: lastReadFileStats.size, 804: write_size: currentStats.size, 805: }) 806: } 807: } catch (e) { 808: const code = getErrnoCode(e) 809: if (code !== 'ENOENT') { 810: throw e 811: } 812: } 813: } 814: const currentConfig = getConfig(file, createDefault) 815: if (file === getGlobalClaudeFile() && wouldLoseAuthState(currentConfig)) { 816: logForDebugging( 817: 'saveConfigWithLock: re-read config is missing auth that cache has; refusing to write to avoid wiping ~/.claude.json. See GH #3117.', 818: { level: 'error' }, 819: ) 820: logEvent('tengu_config_auth_loss_prevented', {}) 821: return false 822: } 823: const mergedConfig = mergeFn(currentConfig) 824: if (mergedConfig === currentConfig) { 825: return false 826: } 827: const filteredConfig = pickBy( 828: mergedConfig, 829: (value, key) => 830: jsonStringify(value) !== jsonStringify(defaultConfig[key as keyof A]), 831: ) 832: try { 833: const fileBase = basename(file) 834: const backupDir = getConfigBackupDir() 835: try { 836: fs.mkdirSync(backupDir) 837: } catch (mkdirErr) { 838: const mkdirCode = getErrnoCode(mkdirErr) 839: if (mkdirCode !== 'EEXIST') { 840: throw mkdirErr 841: } 842: } 843: const MIN_BACKUP_INTERVAL_MS = 60_000 844: const existingBackups = fs 845: .readdirStringSync(backupDir) 846: .filter(f => f.startsWith(`${fileBase}.backup.`)) 847: .sort() 848: .reverse() 849: const mostRecentBackup = existingBackups[0] 850: const mostRecentTimestamp = mostRecentBackup 851: ? Number(mostRecentBackup.split('.backup.').pop()) 852: : 0 853: const shouldCreateBackup = 854: Number.isNaN(mostRecentTimestamp) || 855: Date.now() - mostRecentTimestamp >= MIN_BACKUP_INTERVAL_MS 856: if (shouldCreateBackup) { 857: const backupPath = join(backupDir, `${fileBase}.backup.${Date.now()}`) 858: fs.copyFileSync(file, backupPath) 859: } 860: const MAX_BACKUPS = 5 861: const backupsForCleanup = shouldCreateBackup 862: ? fs 863: .readdirStringSync(backupDir) 864: .filter(f => f.startsWith(`${fileBase}.backup.`)) 865: .sort() 866: .reverse() 867: : existingBackups 868: for (const oldBackup of backupsForCleanup.slice(MAX_BACKUPS)) { 869: try { 870: fs.unlinkSync(join(backupDir, oldBackup)) 871: } catch { 872: } 873: } 874: } catch (e) { 875: const code = getErrnoCode(e) 876: if (code !== 'ENOENT') { 877: logForDebugging(`Failed to backup config: ${e}`, { 878: level: 'error', 879: }) 880: } 881: } 882: writeFileSyncAndFlush_DEPRECATED( 883: file, 884: jsonStringify(filteredConfig, null, 2), 885: { 886: encoding: 'utf-8', 887: mode: 0o600, 888: }, 889: ) 890: if (file === getGlobalClaudeFile()) { 891: globalConfigWriteCount++ 892: } 893: return true 894: } finally { 895: if (release) { 896: release() 897: } 898: } 899: } 900: let configReadingAllowed = false 901: export function enableConfigs(): void { 902: if (configReadingAllowed) { 903: return 904: } 905: const startTime = Date.now() 906: logForDiagnosticsNoPII('info', 'enable_configs_started') 907: configReadingAllowed = true 908: getConfig( 909: getGlobalClaudeFile(), 910: createDefaultGlobalConfig, 911: true , 912: ) 913: logForDiagnosticsNoPII('info', 'enable_configs_completed', { 914: duration_ms: Date.now() - startTime, 915: }) 916: } 917: function getConfigBackupDir(): string { 918: return join(getClaudeConfigHomeDir(), 'backups') 919: } 920: function findMostRecentBackup(file: string): string | null { 921: const fs = getFsImplementation() 922: const fileBase = basename(file) 923: const backupDir = getConfigBackupDir() 924: try { 925: const backups = fs 926: .readdirStringSync(backupDir) 927: .filter(f => f.startsWith(`${fileBase}.backup.`)) 928: .sort() 929: const mostRecent = backups.at(-1) 930: if (mostRecent) { 931: return join(backupDir, mostRecent) 932: } 933: } catch { 934: } 935: const fileDir = dirname(file) 936: try { 937: const backups = fs 938: .readdirStringSync(fileDir) 939: .filter(f => f.startsWith(`${fileBase}.backup.`)) 940: .sort() 941: const mostRecent = backups.at(-1) 942: if (mostRecent) { 943: return join(fileDir, mostRecent) 944: } 945: const legacyBackup = `${file}.backup` 946: try { 947: fs.statSync(legacyBackup) 948: return legacyBackup 949: } catch { 950: } 951: } catch { 952: } 953: return null 954: } 955: function getConfig<A>( 956: file: string, 957: createDefault: () => A, 958: throwOnInvalid?: boolean, 959: ): A { 960: if (!configReadingAllowed && process.env.NODE_ENV !== 'test') { 961: throw new Error('Config accessed before allowed.') 962: } 963: const fs = getFsImplementation() 964: try { 965: const fileContent = fs.readFileSync(file, { 966: encoding: 'utf-8', 967: }) 968: try { 969: const parsedConfig = jsonParse(stripBOM(fileContent)) 970: return { 971: ...createDefault(), 972: ...parsedConfig, 973: } 974: } catch (error) { 975: const errorMessage = 976: error instanceof Error ? error.message : String(error) 977: throw new ConfigParseError(errorMessage, file, createDefault()) 978: } 979: } catch (error) { 980: const errCode = getErrnoCode(error) 981: if (errCode === 'ENOENT') { 982: const backupPath = findMostRecentBackup(file) 983: if (backupPath) { 984: process.stderr.write( 985: `\nClaude configuration file not found at: ${file}\n` + 986: `A backup file exists at: ${backupPath}\n` + 987: `You can manually restore it by running: cp "${backupPath}" "${file}"\n\n`, 988: ) 989: } 990: return createDefault() 991: } 992: if (error instanceof ConfigParseError && throwOnInvalid) { 993: throw error 994: } 995: if (error instanceof ConfigParseError) { 996: logForDebugging( 997: `Config file corrupted, resetting to defaults: ${error.message}`, 998: { level: 'error' }, 999: ) 1000: if (!insideGetConfig) { 1001: insideGetConfig = true 1002: try { 1003: logError(error) 1004: let hasBackup = false 1005: try { 1006: fs.statSync(`${file}.backup`) 1007: hasBackup = true 1008: } catch { 1009: } 1010: logEvent('tengu_config_parse_error', { 1011: has_backup: hasBackup, 1012: }) 1013: } finally { 1014: insideGetConfig = false 1015: } 1016: } 1017: process.stderr.write( 1018: `\nClaude configuration file at ${file} is corrupted: ${error.message}\n`, 1019: ) 1020: const fileBase = basename(file) 1021: const corruptedBackupDir = getConfigBackupDir() 1022: try { 1023: fs.mkdirSync(corruptedBackupDir) 1024: } catch (mkdirErr) { 1025: const mkdirCode = getErrnoCode(mkdirErr) 1026: if (mkdirCode !== 'EEXIST') { 1027: throw mkdirErr 1028: } 1029: } 1030: const existingCorruptedBackups = fs 1031: .readdirStringSync(corruptedBackupDir) 1032: .filter(f => f.startsWith(`${fileBase}.corrupted.`)) 1033: let corruptedBackupPath: string | undefined 1034: let alreadyBackedUp = false 1035: const currentContent = fs.readFileSync(file, { encoding: 'utf-8' }) 1036: for (const backup of existingCorruptedBackups) { 1037: try { 1038: const backupContent = fs.readFileSync( 1039: join(corruptedBackupDir, backup), 1040: { encoding: 'utf-8' }, 1041: ) 1042: if (currentContent === backupContent) { 1043: alreadyBackedUp = true 1044: break 1045: } 1046: } catch { 1047: } 1048: } 1049: if (!alreadyBackedUp) { 1050: corruptedBackupPath = join( 1051: corruptedBackupDir, 1052: `${fileBase}.corrupted.${Date.now()}`, 1053: ) 1054: try { 1055: fs.copyFileSync(file, corruptedBackupPath) 1056: logForDebugging( 1057: `Corrupted config backed up to: ${corruptedBackupPath}`, 1058: { 1059: level: 'error', 1060: }, 1061: ) 1062: } catch { 1063: } 1064: } 1065: const backupPath = findMostRecentBackup(file) 1066: if (corruptedBackupPath) { 1067: process.stderr.write( 1068: `The corrupted file has been backed up to: ${corruptedBackupPath}\n`, 1069: ) 1070: } else if (alreadyBackedUp) { 1071: process.stderr.write(`The corrupted file has already been backed up.\n`) 1072: } 1073: if (backupPath) { 1074: process.stderr.write( 1075: `A backup file exists at: ${backupPath}\n` + 1076: `You can manually restore it by running: cp "${backupPath}" "${file}"\n\n`, 1077: ) 1078: } else { 1079: process.stderr.write(`\n`) 1080: } 1081: } 1082: return createDefault() 1083: } 1084: } 1085: export const getProjectPathForConfig = memoize((): string => { 1086: const originalCwd = getOriginalCwd() 1087: const gitRoot = findCanonicalGitRoot(originalCwd) 1088: if (gitRoot) { 1089: return normalizePathForConfigKey(gitRoot) 1090: } 1091: return normalizePathForConfigKey(resolve(originalCwd)) 1092: }) 1093: export function getCurrentProjectConfig(): ProjectConfig { 1094: if (process.env.NODE_ENV === 'test') { 1095: return TEST_PROJECT_CONFIG_FOR_TESTING 1096: } 1097: const absolutePath = getProjectPathForConfig() 1098: const config = getGlobalConfig() 1099: if (!config.projects) { 1100: return DEFAULT_PROJECT_CONFIG 1101: } 1102: const projectConfig = config.projects[absolutePath] ?? DEFAULT_PROJECT_CONFIG 1103: if (typeof projectConfig.allowedTools === 'string') { 1104: projectConfig.allowedTools = 1105: (safeParseJSON(projectConfig.allowedTools) as string[]) ?? [] 1106: } 1107: return projectConfig 1108: } 1109: export function saveCurrentProjectConfig( 1110: updater: (currentConfig: ProjectConfig) => ProjectConfig, 1111: ): void { 1112: if (process.env.NODE_ENV === 'test') { 1113: const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING) 1114: if (config === TEST_PROJECT_CONFIG_FOR_TESTING) { 1115: return 1116: } 1117: Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config) 1118: return 1119: } 1120: const absolutePath = getProjectPathForConfig() 1121: let written: GlobalConfig | null = null 1122: try { 1123: const didWrite = saveConfigWithLock( 1124: getGlobalClaudeFile(), 1125: createDefaultGlobalConfig, 1126: current => { 1127: const currentProjectConfig = 1128: current.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG 1129: const newProjectConfig = updater(currentProjectConfig) 1130: if (newProjectConfig === currentProjectConfig) { 1131: return current 1132: } 1133: written = { 1134: ...current, 1135: projects: { 1136: ...current.projects, 1137: [absolutePath]: newProjectConfig, 1138: }, 1139: } 1140: return written 1141: }, 1142: ) 1143: if (didWrite && written) { 1144: writeThroughGlobalConfigCache(written) 1145: } 1146: } catch (error) { 1147: logForDebugging(`Failed to save config with lock: ${error}`, { 1148: level: 'error', 1149: }) 1150: const config = getConfig(getGlobalClaudeFile(), createDefaultGlobalConfig) 1151: if (wouldLoseAuthState(config)) { 1152: logForDebugging( 1153: 'saveCurrentProjectConfig fallback: re-read config is missing auth that cache has; refusing to write. See GH #3117.', 1154: { level: 'error' }, 1155: ) 1156: logEvent('tengu_config_auth_loss_prevented', {}) 1157: return 1158: } 1159: const currentProjectConfig = 1160: config.projects?.[absolutePath] ?? DEFAULT_PROJECT_CONFIG 1161: const newProjectConfig = updater(currentProjectConfig) 1162: if (newProjectConfig === currentProjectConfig) { 1163: return 1164: } 1165: written = { 1166: ...config, 1167: projects: { 1168: ...config.projects, 1169: [absolutePath]: newProjectConfig, 1170: }, 1171: } 1172: saveConfig(getGlobalClaudeFile(), written, DEFAULT_GLOBAL_CONFIG) 1173: writeThroughGlobalConfigCache(written) 1174: } 1175: } 1176: export function isAutoUpdaterDisabled(): boolean { 1177: return getAutoUpdaterDisabledReason() !== null 1178: } 1179: export function shouldSkipPluginAutoupdate(): boolean { 1180: return ( 1181: isAutoUpdaterDisabled() && 1182: !isEnvTruthy(process.env.FORCE_AUTOUPDATE_PLUGINS) 1183: ) 1184: } 1185: export type AutoUpdaterDisabledReason = 1186: | { type: 'development' } 1187: | { type: 'env'; envVar: string } 1188: | { type: 'config' } 1189: export function formatAutoUpdaterDisabledReason( 1190: reason: AutoUpdaterDisabledReason, 1191: ): string { 1192: switch (reason.type) { 1193: case 'development': 1194: return 'development build' 1195: case 'env': 1196: return `${reason.envVar} set` 1197: case 'config': 1198: return 'config' 1199: } 1200: } 1201: export function getAutoUpdaterDisabledReason(): AutoUpdaterDisabledReason | null { 1202: if (process.env.NODE_ENV === 'development') { 1203: return { type: 'development' } 1204: } 1205: if (isEnvTruthy(process.env.DISABLE_AUTOUPDATER)) { 1206: return { type: 'env', envVar: 'DISABLE_AUTOUPDATER' } 1207: } 1208: const essentialTrafficEnvVar = getEssentialTrafficOnlyReason() 1209: if (essentialTrafficEnvVar) { 1210: return { type: 'env', envVar: essentialTrafficEnvVar } 1211: } 1212: const config = getGlobalConfig() 1213: if ( 1214: config.autoUpdates === false && 1215: (config.installMethod !== 'native' || 1216: config.autoUpdatesProtectedForNative !== true) 1217: ) { 1218: return { type: 'config' } 1219: } 1220: return null 1221: } 1222: export function getOrCreateUserID(): string { 1223: const config = getGlobalConfig() 1224: if (config.userID) { 1225: return config.userID 1226: } 1227: const userID = randomBytes(32).toString('hex') 1228: saveGlobalConfig(current => ({ ...current, userID })) 1229: return userID 1230: } 1231: export function recordFirstStartTime(): void { 1232: const config = getGlobalConfig() 1233: if (!config.firstStartTime) { 1234: const firstStartTime = new Date().toISOString() 1235: saveGlobalConfig(current => ({ 1236: ...current, 1237: firstStartTime: current.firstStartTime ?? firstStartTime, 1238: })) 1239: } 1240: } 1241: export function getMemoryPath(memoryType: MemoryType): string { 1242: const cwd = getOriginalCwd() 1243: switch (memoryType) { 1244: case 'User': 1245: return join(getClaudeConfigHomeDir(), 'CLAUDE.md') 1246: case 'Local': 1247: return join(cwd, 'CLAUDE.local.md') 1248: case 'Project': 1249: return join(cwd, 'CLAUDE.md') 1250: case 'Managed': 1251: return join(getManagedFilePath(), 'CLAUDE.md') 1252: case 'AutoMem': 1253: return getAutoMemEntrypoint() 1254: } 1255: if (feature('TEAMMEM')) { 1256: return teamMemPaths!.getTeamMemEntrypoint() 1257: } 1258: return '' // unreachable in external builds where TeamMem is not in MemoryType 1259: } 1260: export function getManagedClaudeRulesDir(): string { 1261: return join(getManagedFilePath(), '.claude', 'rules') 1262: } 1263: export function getUserClaudeRulesDir(): string { 1264: return join(getClaudeConfigHomeDir(), 'rules') 1265: } 1266: export const _getConfigForTesting = getConfig 1267: export const _wouldLoseAuthStateForTesting = wouldLoseAuthState 1268: export function _setGlobalConfigCacheForTesting( 1269: config: GlobalConfig | null, 1270: ): void { 1271: globalConfigCache.config = config 1272: globalConfigCache.mtime = config ? Date.now() : 0 1273: }

File: src/utils/configConstants.ts

typescript 1: export const NOTIFICATION_CHANNELS = [ 2: 'auto', 3: 'iterm2', 4: 'iterm2_with_bell', 5: 'terminal_bell', 6: 'kitty', 7: 'ghostty', 8: 'notifications_disabled', 9: ] as const 10: export const EDITOR_MODES = ['normal', 'vim'] as const 11: export const TEAMMATE_MODES = ['auto', 'tmux', 'in-process'] as const

File: src/utils/contentArray.ts

typescript 1: export function insertBlockAfterToolResults( 2: content: unknown[], 3: block: unknown, 4: ): void { 5: let lastToolResultIndex = -1 6: for (let i = 0; i < content.length; i++) { 7: const item = content[i] 8: if ( 9: item && 10: typeof item === 'object' && 11: 'type' in item && 12: (item as { type: string }).type === 'tool_result' 13: ) { 14: lastToolResultIndex = i 15: } 16: } 17: if (lastToolResultIndex >= 0) { 18: const insertPos = lastToolResultIndex + 1 19: content.splice(insertPos, 0, block) 20: if (insertPos === content.length - 1) { 21: content.push({ type: 'text', text: '.' }) 22: } 23: } else { 24: const insertIndex = Math.max(0, content.length - 1) 25: content.splice(insertIndex, 0, block) 26: } 27: }

File: src/utils/context.ts

typescript 1: import { CONTEXT_1M_BETA_HEADER } from '../constants/betas.js' 2: import { getGlobalConfig } from './config.js' 3: import { isEnvTruthy } from './envUtils.js' 4: import { getCanonicalName } from './model/model.js' 5: import { getModelCapability } from './model/modelCapabilities.js' 6: export const MODEL_CONTEXT_WINDOW_DEFAULT = 200_000 7: export const COMPACT_MAX_OUTPUT_TOKENS = 20_000 8: const MAX_OUTPUT_TOKENS_DEFAULT = 32_000 9: const MAX_OUTPUT_TOKENS_UPPER_LIMIT = 64_000 10: export const CAPPED_DEFAULT_MAX_TOKENS = 8_000 11: export const ESCALATED_MAX_TOKENS = 64_000 12: export function is1mContextDisabled(): boolean { 13: return isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_1M_CONTEXT) 14: } 15: export function has1mContext(model: string): boolean { 16: if (is1mContextDisabled()) { 17: return false 18: } 19: return /\[1m\]/i.test(model) 20: } 21: export function modelSupports1M(model: string): boolean { 22: if (is1mContextDisabled()) { 23: return false 24: } 25: const canonical = getCanonicalName(model) 26: return canonical.includes('claude-sonnet-4') || canonical.includes('opus-4-6') 27: } 28: export function getContextWindowForModel( 29: model: string, 30: betas?: string[], 31: ): number { 32: if ( 33: process.env.USER_TYPE === 'ant' && 34: process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS 35: ) { 36: const override = parseInt(process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, 10) 37: if (!isNaN(override) && override > 0) { 38: return override 39: } 40: } 41: if (has1mContext(model)) { 42: return 1_000_000 43: } 44: const cap = getModelCapability(model) 45: if (cap?.max_input_tokens && cap.max_input_tokens >= 100_000) { 46: if ( 47: cap.max_input_tokens > MODEL_CONTEXT_WINDOW_DEFAULT && 48: is1mContextDisabled() 49: ) { 50: return MODEL_CONTEXT_WINDOW_DEFAULT 51: } 52: return cap.max_input_tokens 53: } 54: if (betas?.includes(CONTEXT_1M_BETA_HEADER) && modelSupports1M(model)) { 55: return 1_000_000 56: } 57: if (getSonnet1mExpTreatmentEnabled(model)) { 58: return 1_000_000 59: } 60: if (process.env.USER_TYPE === 'ant') { 61: const antModel = resolveAntModel(model) 62: if (antModel?.contextWindow) { 63: return antModel.contextWindow 64: } 65: } 66: return MODEL_CONTEXT_WINDOW_DEFAULT 67: } 68: export function getSonnet1mExpTreatmentEnabled(model: string): boolean { 69: if (is1mContextDisabled()) { 70: return false 71: } 72: if (has1mContext(model)) { 73: return false 74: } 75: if (!getCanonicalName(model).includes('sonnet-4-6')) { 76: return false 77: } 78: return getGlobalConfig().clientDataCache?.['coral_reef_sonnet'] === 'true' 79: } 80: export function calculateContextPercentages( 81: currentUsage: { 82: input_tokens: number 83: cache_creation_input_tokens: number 84: cache_read_input_tokens: number 85: } | null, 86: contextWindowSize: number, 87: ): { used: number | null; remaining: number | null } { 88: if (!currentUsage) { 89: return { used: null, remaining: null } 90: } 91: const totalInputTokens = 92: currentUsage.input_tokens + 93: currentUsage.cache_creation_input_tokens + 94: currentUsage.cache_read_input_tokens 95: const usedPercentage = Math.round( 96: (totalInputTokens / contextWindowSize) * 100, 97: ) 98: const clampedUsed = Math.min(100, Math.max(0, usedPercentage)) 99: return { 100: used: clampedUsed, 101: remaining: 100 - clampedUsed, 102: } 103: } 104: export function getModelMaxOutputTokens(model: string): { 105: default: number 106: upperLimit: number 107: } { 108: let defaultTokens: number 109: let upperLimit: number 110: if (process.env.USER_TYPE === 'ant') { 111: const antModel = resolveAntModel(model.toLowerCase()) 112: if (antModel) { 113: defaultTokens = antModel.defaultMaxTokens ?? MAX_OUTPUT_TOKENS_DEFAULT 114: upperLimit = antModel.upperMaxTokensLimit ?? MAX_OUTPUT_TOKENS_UPPER_LIMIT 115: return { default: defaultTokens, upperLimit } 116: } 117: } 118: const m = getCanonicalName(model) 119: if (m.includes('opus-4-6')) { 120: defaultTokens = 64_000 121: upperLimit = 128_000 122: } else if (m.includes('sonnet-4-6')) { 123: defaultTokens = 32_000 124: upperLimit = 128_000 125: } else if ( 126: m.includes('opus-4-5') || 127: m.includes('sonnet-4') || 128: m.includes('haiku-4') 129: ) { 130: defaultTokens = 32_000 131: upperLimit = 64_000 132: } else if (m.includes('opus-4-1') || m.includes('opus-4')) { 133: defaultTokens = 32_000 134: upperLimit = 32_000 135: } else if (m.includes('claude-3-opus')) { 136: defaultTokens = 4_096 137: upperLimit = 4_096 138: } else if (m.includes('claude-3-sonnet')) { 139: defaultTokens = 8_192 140: upperLimit = 8_192 141: } else if (m.includes('claude-3-haiku')) { 142: defaultTokens = 4_096 143: upperLimit = 4_096 144: } else if (m.includes('3-5-sonnet') || m.includes('3-5-haiku')) { 145: defaultTokens = 8_192 146: upperLimit = 8_192 147: } else if (m.includes('3-7-sonnet')) { 148: defaultTokens = 32_000 149: upperLimit = 64_000 150: } else { 151: defaultTokens = MAX_OUTPUT_TOKENS_DEFAULT 152: upperLimit = MAX_OUTPUT_TOKENS_UPPER_LIMIT 153: } 154: const cap = getModelCapability(model) 155: if (cap?.max_tokens && cap.max_tokens >= 4_096) { 156: upperLimit = cap.max_tokens 157: defaultTokens = Math.min(defaultTokens, upperLimit) 158: } 159: return { default: defaultTokens, upperLimit } 160: } 161: export function getMaxThinkingTokensForModel(model: string): number { 162: return getModelMaxOutputTokens(model).upperLimit - 1 163: }

File: src/utils/contextAnalysis.ts

typescript 1: import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2: import type { 3: ContentBlock, 4: ContentBlockParam, 5: } from '@anthropic-ai/sdk/resources/index.mjs' 6: import { roughTokenCountEstimation as countTokens } from '../services/tokenEstimation.js' 7: import type { 8: AssistantMessage, 9: Message, 10: UserMessage, 11: } from '../types/message.js' 12: import { normalizeMessagesForAPI } from './messages.js' 13: import { jsonStringify } from './slowOperations.js' 14: type TokenStats = { 15: toolRequests: Map<string, number> 16: toolResults: Map<string, number> 17: humanMessages: number 18: assistantMessages: number 19: localCommandOutputs: number 20: other: number 21: attachments: Map<string, number> 22: duplicateFileReads: Map<string, { count: number; tokens: number }> 23: total: number 24: } 25: export function analyzeContext(messages: Message[]): TokenStats { 26: const stats: TokenStats = { 27: toolRequests: new Map(), 28: toolResults: new Map(), 29: humanMessages: 0, 30: assistantMessages: 0, 31: localCommandOutputs: 0, 32: other: 0, 33: attachments: new Map(), 34: duplicateFileReads: new Map(), 35: total: 0, 36: } 37: const toolIdsToToolNames = new Map<string, string>() 38: const readToolIdToFilePath = new Map<string, string>() 39: const fileReadStats = new Map< 40: string, 41: { count: number; totalTokens: number } 42: >() 43: messages.forEach(msg => { 44: if (msg.type === 'attachment') { 45: const type = msg.attachment.type || 'unknown' 46: stats.attachments.set(type, (stats.attachments.get(type) || 0) + 1) 47: } 48: }) 49: const normalizedMessages = normalizeMessagesForAPI(messages) 50: normalizedMessages.forEach(msg => { 51: const { content } = msg.message 52: if (typeof content === 'string') { 53: const tokens = countTokens(content) 54: stats.total += tokens 55: if (msg.type === 'user' && content.includes('local-command-stdout')) { 56: stats.localCommandOutputs += tokens 57: } else { 58: stats[msg.type === 'user' ? 'humanMessages' : 'assistantMessages'] += 59: tokens 60: } 61: } else { 62: content.forEach(block => 63: processBlock( 64: block, 65: msg, 66: stats, 67: toolIdsToToolNames, 68: readToolIdToFilePath, 69: fileReadStats, 70: ), 71: ) 72: } 73: }) 74: fileReadStats.forEach((data, path) => { 75: if (data.count > 1) { 76: const averageTokensPerRead = Math.floor(data.totalTokens / data.count) 77: const duplicateTokens = averageTokensPerRead * (data.count - 1) 78: stats.duplicateFileReads.set(path, { 79: count: data.count, 80: tokens: duplicateTokens, 81: }) 82: } 83: }) 84: return stats 85: } 86: function processBlock( 87: block: ContentBlockParam | ContentBlock | BetaContentBlock, 88: message: UserMessage | AssistantMessage, 89: stats: TokenStats, 90: toolIds: Map<string, string>, 91: readToolPaths: Map<string, string>, 92: fileReads: Map<string, { count: number; totalTokens: number }>, 93: ): void { 94: const tokens = countTokens(jsonStringify(block)) 95: stats.total += tokens 96: switch (block.type) { 97: case 'text': 98: if ( 99: message.type === 'user' && 100: 'text' in block && 101: block.text.includes('local-command-stdout') 102: ) { 103: stats.localCommandOutputs += tokens 104: } else { 105: stats[ 106: message.type === 'user' ? 'humanMessages' : 'assistantMessages' 107: ] += tokens 108: } 109: break 110: case 'tool_use': { 111: if ('name' in block && 'id' in block) { 112: const toolName = block.name || 'unknown' 113: increment(stats.toolRequests, toolName, tokens) 114: toolIds.set(block.id, toolName) 115: if ( 116: toolName === 'Read' && 117: 'input' in block && 118: block.input && 119: typeof block.input === 'object' && 120: 'file_path' in block.input 121: ) { 122: const path = String( 123: (block.input as Record<string, unknown>).file_path, 124: ) 125: readToolPaths.set(block.id, path) 126: } 127: } 128: break 129: } 130: case 'tool_result': { 131: if ('tool_use_id' in block) { 132: const toolName = toolIds.get(block.tool_use_id) || 'unknown' 133: increment(stats.toolResults, toolName, tokens) 134: if (toolName === 'Read') { 135: const path = readToolPaths.get(block.tool_use_id) 136: if (path) { 137: const current = fileReads.get(path) || { count: 0, totalTokens: 0 } 138: fileReads.set(path, { 139: count: current.count + 1, 140: totalTokens: current.totalTokens + tokens, 141: }) 142: } 143: } 144: } 145: break 146: } 147: case 'image': 148: case 'server_tool_use': 149: case 'web_search_tool_result': 150: case 'search_result': 151: case 'document': 152: case 'thinking': 153: case 'redacted_thinking': 154: case 'code_execution_tool_result': 155: case 'mcp_tool_use': 156: case 'mcp_tool_result': 157: case 'container_upload': 158: case 'web_fetch_tool_result': 159: case 'bash_code_execution_tool_result': 160: case 'text_editor_code_execution_tool_result': 161: case 'tool_search_tool_result': 162: case 'compaction': 163: stats['other'] += tokens 164: break 165: } 166: } 167: function increment(map: Map<string, number>, key: string, value: number): void { 168: map.set(key, (map.get(key) || 0) + value) 169: } 170: export function tokenStatsToStatsigMetrics( 171: stats: TokenStats, 172: ): Record<string, number> { 173: const metrics: Record<string, number> = { 174: total_tokens: stats.total, 175: human_message_tokens: stats.humanMessages, 176: assistant_message_tokens: stats.assistantMessages, 177: local_command_output_tokens: stats.localCommandOutputs, 178: other_tokens: stats.other, 179: } 180: stats.attachments.forEach((count, type) => { 181: metrics[`attachment_${type}_count`] = count 182: }) 183: stats.toolRequests.forEach((tokens, tool) => { 184: metrics[`tool_request_${tool}_tokens`] = tokens 185: }) 186: stats.toolResults.forEach((tokens, tool) => { 187: metrics[`tool_result_${tool}_tokens`] = tokens 188: }) 189: const duplicateTotal = [...stats.duplicateFileReads.values()].reduce( 190: (sum, d) => sum + d.tokens, 191: 0, 192: ) 193: metrics.duplicate_read_tokens = duplicateTotal 194: metrics.duplicate_read_file_count = stats.duplicateFileReads.size 195: if (stats.total > 0) { 196: metrics.human_message_percent = Math.round( 197: (stats.humanMessages / stats.total) * 100, 198: ) 199: metrics.assistant_message_percent = Math.round( 200: (stats.assistantMessages / stats.total) * 100, 201: ) 202: metrics.local_command_output_percent = Math.round( 203: (stats.localCommandOutputs / stats.total) * 100, 204: ) 205: metrics.duplicate_read_percent = Math.round( 206: (duplicateTotal / stats.total) * 100, 207: ) 208: const toolRequestTotal = [...stats.toolRequests.values()].reduce( 209: (sum, v) => sum + v, 210: 0, 211: ) 212: const toolResultTotal = [...stats.toolResults.values()].reduce( 213: (sum, v) => sum + v, 214: 0, 215: ) 216: metrics.tool_request_percent = Math.round( 217: (toolRequestTotal / stats.total) * 100, 218: ) 219: metrics.tool_result_percent = Math.round( 220: (toolResultTotal / stats.total) * 100, 221: ) 222: stats.toolRequests.forEach((tokens, tool) => { 223: metrics[`tool_request_${tool}_percent`] = Math.round( 224: (tokens / stats.total) * 100, 225: ) 226: }) 227: stats.toolResults.forEach((tokens, tool) => { 228: metrics[`tool_result_${tool}_percent`] = Math.round( 229: (tokens / stats.total) * 100, 230: ) 231: }) 232: } 233: return metrics 234: }

File: src/utils/contextSuggestions.ts

typescript 1: import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js' 2: import { FILE_READ_TOOL_NAME } from '../tools/FileReadTool/prompt.js' 3: import { GREP_TOOL_NAME } from '../tools/GrepTool/prompt.js' 4: import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js' 5: import type { ContextData } from './analyzeContext.js' 6: import { getDisplayPath } from './file.js' 7: import { formatTokens } from './format.js' 8: export type SuggestionSeverity = 'info' | 'warning' 9: export type ContextSuggestion = { 10: severity: SuggestionSeverity 11: title: string 12: detail: string 13: savingsTokens?: number 14: } 15: const LARGE_TOOL_RESULT_PERCENT = 15 16: const LARGE_TOOL_RESULT_TOKENS = 10_000 17: const READ_BLOAT_PERCENT = 5 18: const NEAR_CAPACITY_PERCENT = 80 19: const MEMORY_HIGH_PERCENT = 5 20: const MEMORY_HIGH_TOKENS = 5_000 21: export function generateContextSuggestions( 22: data: ContextData, 23: ): ContextSuggestion[] { 24: const suggestions: ContextSuggestion[] = [] 25: checkNearCapacity(data, suggestions) 26: checkLargeToolResults(data, suggestions) 27: checkReadResultBloat(data, suggestions) 28: checkMemoryBloat(data, suggestions) 29: checkAutoCompactDisabled(data, suggestions) 30: suggestions.sort((a, b) => { 31: if (a.severity !== b.severity) { 32: return a.severity === 'warning' ? -1 : 1 33: } 34: return (b.savingsTokens ?? 0) - (a.savingsTokens ?? 0) 35: }) 36: return suggestions 37: } 38: function checkNearCapacity( 39: data: ContextData, 40: suggestions: ContextSuggestion[], 41: ): void { 42: if (data.percentage >= NEAR_CAPACITY_PERCENT) { 43: suggestions.push({ 44: severity: 'warning', 45: title: `Context is ${data.percentage}% full`, 46: detail: data.isAutoCompactEnabled 47: ? 'Autocompact will trigger soon, which discards older messages. Use /compact now to control what gets kept.' 48: : 'Autocompact is disabled. Use /compact to free space, or enable autocompact in /config.', 49: }) 50: } 51: } 52: function checkLargeToolResults( 53: data: ContextData, 54: suggestions: ContextSuggestion[], 55: ): void { 56: if (!data.messageBreakdown) return 57: for (const tool of data.messageBreakdown.toolCallsByType) { 58: const totalToolTokens = tool.callTokens + tool.resultTokens 59: const percent = (totalToolTokens / data.rawMaxTokens) * 100 60: if ( 61: percent < LARGE_TOOL_RESULT_PERCENT || 62: totalToolTokens < LARGE_TOOL_RESULT_TOKENS 63: ) { 64: continue 65: } 66: const suggestion = getLargeToolSuggestion( 67: tool.name, 68: totalToolTokens, 69: percent, 70: ) 71: if (suggestion) { 72: suggestions.push(suggestion) 73: } 74: } 75: } 76: function getLargeToolSuggestion( 77: toolName: string, 78: tokens: number, 79: percent: number, 80: ): ContextSuggestion | null { 81: const tokenStr = formatTokens(tokens) 82: switch (toolName) { 83: case BASH_TOOL_NAME: 84: return { 85: severity: 'warning', 86: title: `Bash results using ${tokenStr} tokens (${percent.toFixed(0)}%)`, 87: detail: 88: 'Pipe output through head, tail, or grep to reduce result size. Avoid cat on large files \u2014 use Read with offset/limit instead.', 89: savingsTokens: Math.floor(tokens * 0.5), 90: } 91: case FILE_READ_TOOL_NAME: 92: return { 93: severity: 'info', 94: title: `Read results using ${tokenStr} tokens (${percent.toFixed(0)}%)`, 95: detail: 96: 'Use offset and limit parameters to read only the sections you need. Avoid re-reading entire files when you only need a few lines.', 97: savingsTokens: Math.floor(tokens * 0.3), 98: } 99: case GREP_TOOL_NAME: 100: return { 101: severity: 'info', 102: title: `Grep results using ${tokenStr} tokens (${percent.toFixed(0)}%)`, 103: detail: 104: 'Add more specific patterns or use the glob or type parameter to narrow file types. Consider Glob for file discovery instead of Grep.', 105: savingsTokens: Math.floor(tokens * 0.3), 106: } 107: case WEB_FETCH_TOOL_NAME: 108: return { 109: severity: 'info', 110: title: `WebFetch results using ${tokenStr} tokens (${percent.toFixed(0)}%)`, 111: detail: 112: 'Web page content can be very large. Consider extracting only the specific information needed.', 113: savingsTokens: Math.floor(tokens * 0.4), 114: } 115: default: 116: if (percent >= 20) { 117: return { 118: severity: 'info', 119: title: `${toolName} using ${tokenStr} tokens (${percent.toFixed(0)}%)`, 120: detail: `This tool is consuming a significant portion of context.`, 121: savingsTokens: Math.floor(tokens * 0.2), 122: } 123: } 124: return null 125: } 126: } 127: function checkReadResultBloat( 128: data: ContextData, 129: suggestions: ContextSuggestion[], 130: ): void { 131: if (!data.messageBreakdown) return 132: const callsByType = data.messageBreakdown.toolCallsByType 133: const readTool = callsByType.find(t => t.name === FILE_READ_TOOL_NAME) 134: if (!readTool) return 135: const totalReadTokens = readTool.callTokens + readTool.resultTokens 136: const totalReadPercent = (totalReadTokens / data.rawMaxTokens) * 100 137: const readPercent = (readTool.resultTokens / data.rawMaxTokens) * 100 138: if ( 139: totalReadPercent >= LARGE_TOOL_RESULT_PERCENT && 140: totalReadTokens >= LARGE_TOOL_RESULT_TOKENS 141: ) { 142: return 143: } 144: if ( 145: readPercent >= READ_BLOAT_PERCENT && 146: readTool.resultTokens >= LARGE_TOOL_RESULT_TOKENS 147: ) { 148: suggestions.push({ 149: severity: 'info', 150: title: `File reads using ${formatTokens(readTool.resultTokens)} tokens (${readPercent.toFixed(0)}%)`, 151: detail: 152: 'If you are re-reading files, consider referencing earlier reads. Use offset/limit for large files.', 153: savingsTokens: Math.floor(readTool.resultTokens * 0.3), 154: }) 155: } 156: } 157: function checkMemoryBloat( 158: data: ContextData, 159: suggestions: ContextSuggestion[], 160: ): void { 161: const totalMemoryTokens = data.memoryFiles.reduce( 162: (sum, f) => sum + f.tokens, 163: 0, 164: ) 165: const memoryPercent = (totalMemoryTokens / data.rawMaxTokens) * 100 166: if ( 167: memoryPercent >= MEMORY_HIGH_PERCENT && 168: totalMemoryTokens >= MEMORY_HIGH_TOKENS 169: ) { 170: const largestFiles = [...data.memoryFiles] 171: .sort((a, b) => b.tokens - a.tokens) 172: .slice(0, 3) 173: .map(f => { 174: const name = getDisplayPath(f.path) 175: return `${name} (${formatTokens(f.tokens)})` 176: }) 177: .join(', ') 178: suggestions.push({ 179: severity: 'info', 180: title: `Memory files using ${formatTokens(totalMemoryTokens)} tokens (${memoryPercent.toFixed(0)}%)`, 181: detail: `Largest: ${largestFiles}. Use /memory to review and prune stale entries.`, 182: savingsTokens: Math.floor(totalMemoryTokens * 0.3), 183: }) 184: } 185: } 186: function checkAutoCompactDisabled( 187: data: ContextData, 188: suggestions: ContextSuggestion[], 189: ): void { 190: if ( 191: !data.isAutoCompactEnabled && 192: data.percentage >= 50 && 193: data.percentage < NEAR_CAPACITY_PERCENT 194: ) { 195: suggestions.push({ 196: severity: 'info', 197: title: 'Autocompact is disabled', 198: detail: 199: 'Without autocompact, you will hit context limits and lose the conversation. Enable it in /config or use /compact manually.', 200: }) 201: } 202: }

File: src/utils/controlMessageCompat.ts

typescript 1: export function normalizeControlMessageKeys(obj: unknown): unknown { 2: if (obj === null || typeof obj !== 'object') return obj 3: const record = obj as Record<string, unknown> 4: if ('requestId' in record && !('request_id' in record)) { 5: record.request_id = record.requestId 6: delete record.requestId 7: } 8: if ( 9: 'response' in record && 10: record.response !== null && 11: typeof record.response === 'object' 12: ) { 13: const response = record.response as Record<string, unknown> 14: if ('requestId' in response && !('request_id' in response)) { 15: response.request_id = response.requestId 16: delete response.requestId 17: } 18: } 19: return obj 20: }

File: src/utils/conversationRecovery.ts

typescript 1: import { feature } from 'bun:bundle' 2: import type { UUID } from 'crypto' 3: import { relative } from 'path' 4: import { getCwd } from 'src/utils/cwd.js' 5: import { addInvokedSkill } from '../bootstrap/state.js' 6: import { asSessionId } from '../types/ids.js' 7: import type { 8: AttributionSnapshotMessage, 9: ContextCollapseCommitEntry, 10: ContextCollapseSnapshotEntry, 11: LogOption, 12: PersistedWorktreeSession, 13: SerializedMessage, 14: } from '../types/logs.js' 15: import type { 16: Message, 17: NormalizedMessage, 18: NormalizedUserMessage, 19: } from '../types/message.js' 20: import { PERMISSION_MODES } from '../types/permissions.js' 21: import { suppressNextSkillListing } from './attachments.js' 22: import { 23: copyFileHistoryForResume, 24: type FileHistorySnapshot, 25: } from './fileHistory.js' 26: import { logError } from './log.js' 27: import { 28: createAssistantMessage, 29: createUserMessage, 30: filterOrphanedThinkingOnlyMessages, 31: filterUnresolvedToolUses, 32: filterWhitespaceOnlyAssistantMessages, 33: isToolUseResultMessage, 34: NO_RESPONSE_REQUESTED, 35: normalizeMessages, 36: } from './messages.js' 37: import { copyPlanForResume } from './plans.js' 38: import { processSessionStartHooks } from './sessionStart.js' 39: import { 40: buildConversationChain, 41: checkResumeConsistency, 42: getLastSessionLog, 43: getSessionIdFromLog, 44: isLiteLog, 45: loadFullLog, 46: loadMessageLogs, 47: loadTranscriptFile, 48: removeExtraFields, 49: } from './sessionStorage.js' 50: import type { ContentReplacementRecord } from './toolResultStorage.js' 51: const BRIEF_TOOL_NAME: string | null = 52: feature('KAIROS') || feature('KAIROS_BRIEF') 53: ? ( 54: require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') 55: ).BRIEF_TOOL_NAME 56: : null 57: const LEGACY_BRIEF_TOOL_NAME: string | null = 58: feature('KAIROS') || feature('KAIROS_BRIEF') 59: ? ( 60: require('../tools/BriefTool/prompt.js') as typeof import('../tools/BriefTool/prompt.js') 61: ).LEGACY_BRIEF_TOOL_NAME 62: : null 63: const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') 64: ? ( 65: require('../tools/SendUserFileTool/prompt.js') as typeof import('../tools/SendUserFileTool/prompt.js') 66: ).SEND_USER_FILE_TOOL_NAME 67: : null 68: function migrateLegacyAttachmentTypes(message: Message): Message { 69: if (message.type !== 'attachment') { 70: return message 71: } 72: const attachment = message.attachment as { 73: type: string 74: [key: string]: unknown 75: } 76: if (attachment.type === 'new_file') { 77: return { 78: ...message, 79: attachment: { 80: ...attachment, 81: type: 'file', 82: displayPath: relative(getCwd(), attachment.filename as string), 83: }, 84: } as SerializedMessage 85: } 86: if (attachment.type === 'new_directory') { 87: return { 88: ...message, 89: attachment: { 90: ...attachment, 91: type: 'directory', 92: displayPath: relative(getCwd(), attachment.path as string), 93: }, 94: } as SerializedMessage 95: } 96: if (!('displayPath' in attachment)) { 97: const path = 98: 'filename' in attachment 99: ? (attachment.filename as string) 100: : 'path' in attachment 101: ? (attachment.path as string) 102: : 'skillDir' in attachment 103: ? (attachment.skillDir as string) 104: : undefined 105: if (path) { 106: return { 107: ...message, 108: attachment: { 109: ...attachment, 110: displayPath: relative(getCwd(), path), 111: }, 112: } as Message 113: } 114: } 115: return message 116: } 117: export type TeleportRemoteResponse = { 118: log: Message[] 119: branch?: string 120: } 121: export type TurnInterruptionState = 122: | { kind: 'none' } 123: | { kind: 'interrupted_prompt'; message: NormalizedUserMessage } 124: export type DeserializeResult = { 125: messages: Message[] 126: turnInterruptionState: TurnInterruptionState 127: } 128: export function deserializeMessages(serializedMessages: Message[]): Message[] { 129: return deserializeMessagesWithInterruptDetection(serializedMessages).messages 130: } 131: export function deserializeMessagesWithInterruptDetection( 132: serializedMessages: Message[], 133: ): DeserializeResult { 134: try { 135: const migratedMessages = serializedMessages.map( 136: migrateLegacyAttachmentTypes, 137: ) 138: const validModes = new Set<string>(PERMISSION_MODES) 139: for (const msg of migratedMessages) { 140: if ( 141: msg.type === 'user' && 142: msg.permissionMode !== undefined && 143: !validModes.has(msg.permissionMode) 144: ) { 145: msg.permissionMode = undefined 146: } 147: } 148: const filteredToolUses = filterUnresolvedToolUses( 149: migratedMessages, 150: ) as NormalizedMessage[] 151: const filteredThinking = filterOrphanedThinkingOnlyMessages( 152: filteredToolUses, 153: ) as NormalizedMessage[] 154: const filteredMessages = filterWhitespaceOnlyAssistantMessages( 155: filteredThinking, 156: ) as NormalizedMessage[] 157: const internalState = detectTurnInterruption(filteredMessages) 158: let turnInterruptionState: TurnInterruptionState 159: if (internalState.kind === 'interrupted_turn') { 160: const [continuationMessage] = normalizeMessages([ 161: createUserMessage({ 162: content: 'Continue from where you left off.', 163: isMeta: true, 164: }), 165: ]) 166: filteredMessages.push(continuationMessage!) 167: turnInterruptionState = { 168: kind: 'interrupted_prompt', 169: message: continuationMessage!, 170: } 171: } else { 172: turnInterruptionState = internalState 173: } 174: const lastRelevantIdx = filteredMessages.findLastIndex( 175: m => m.type !== 'system' && m.type !== 'progress', 176: ) 177: if ( 178: lastRelevantIdx !== -1 && 179: filteredMessages[lastRelevantIdx]!.type === 'user' 180: ) { 181: filteredMessages.splice( 182: lastRelevantIdx + 1, 183: 0, 184: createAssistantMessage({ 185: content: NO_RESPONSE_REQUESTED, 186: }) as NormalizedMessage, 187: ) 188: } 189: return { messages: filteredMessages, turnInterruptionState } 190: } catch (error) { 191: logError(error as Error) 192: throw error 193: } 194: } 195: type InternalInterruptionState = 196: | TurnInterruptionState 197: | { kind: 'interrupted_turn' } 198: function detectTurnInterruption( 199: messages: NormalizedMessage[], 200: ): InternalInterruptionState { 201: if (messages.length === 0) { 202: return { kind: 'none' } 203: } 204: const lastMessageIdx = messages.findLastIndex( 205: m => 206: m.type !== 'system' && 207: m.type !== 'progress' && 208: !(m.type === 'assistant' && m.isApiErrorMessage), 209: ) 210: const lastMessage = 211: lastMessageIdx !== -1 ? messages[lastMessageIdx] : undefined 212: if (!lastMessage) { 213: return { kind: 'none' } 214: } 215: if (lastMessage.type === 'assistant') { 216: return { kind: 'none' } 217: } 218: if (lastMessage.type === 'user') { 219: if (lastMessage.isMeta || lastMessage.isCompactSummary) { 220: return { kind: 'none' } 221: } 222: if (isToolUseResultMessage(lastMessage)) { 223: if (isTerminalToolResult(lastMessage, messages, lastMessageIdx)) { 224: return { kind: 'none' } 225: } 226: return { kind: 'interrupted_turn' } 227: } 228: return { kind: 'interrupted_prompt', message: lastMessage } 229: } 230: if (lastMessage.type === 'attachment') { 231: return { kind: 'interrupted_turn' } 232: } 233: return { kind: 'none' } 234: } 235: function isTerminalToolResult( 236: result: NormalizedUserMessage, 237: messages: NormalizedMessage[], 238: resultIdx: number, 239: ): boolean { 240: const content = result.message.content 241: if (!Array.isArray(content)) return false 242: const block = content[0] 243: if (block?.type !== 'tool_result') return false 244: const toolUseId = block.tool_use_id 245: for (let i = resultIdx - 1; i >= 0; i--) { 246: const msg = messages[i]! 247: if (msg.type !== 'assistant') continue 248: for (const b of msg.message.content) { 249: if (b.type === 'tool_use' && b.id === toolUseId) { 250: return ( 251: b.name === BRIEF_TOOL_NAME || 252: b.name === LEGACY_BRIEF_TOOL_NAME || 253: b.name === SEND_USER_FILE_TOOL_NAME 254: ) 255: } 256: } 257: } 258: return false 259: } 260: export function restoreSkillStateFromMessages(messages: Message[]): void { 261: for (const message of messages) { 262: if (message.type !== 'attachment') { 263: continue 264: } 265: if (message.attachment.type === 'invoked_skills') { 266: for (const skill of message.attachment.skills) { 267: if (skill.name && skill.path && skill.content) { 268: addInvokedSkill(skill.name, skill.path, skill.content, null) 269: } 270: } 271: } 272: if (message.attachment.type === 'skill_listing') { 273: suppressNextSkillListing() 274: } 275: } 276: } 277: export async function loadMessagesFromJsonlPath(path: string): Promise<{ 278: messages: SerializedMessage[] 279: sessionId: UUID | undefined 280: }> { 281: const { messages: byUuid, leafUuids } = await loadTranscriptFile(path) 282: let tip: (typeof byUuid extends Map<UUID, infer T> ? T : never) | null = null 283: let tipTs = 0 284: for (const m of byUuid.values()) { 285: if (m.isSidechain || !leafUuids.has(m.uuid)) continue 286: const ts = new Date(m.timestamp).getTime() 287: if (ts > tipTs) { 288: tipTs = ts 289: tip = m 290: } 291: } 292: if (!tip) return { messages: [], sessionId: undefined } 293: const chain = buildConversationChain(byUuid, tip) 294: return { 295: messages: removeExtraFields(chain), 296: sessionId: tip.sessionId as UUID | undefined, 297: } 298: } 299: export async function loadConversationForResume( 300: source: string | LogOption | undefined, 301: sourceJsonlFile: string | undefined, 302: ): Promise<{ 303: messages: Message[] 304: turnInterruptionState: TurnInterruptionState 305: fileHistorySnapshots?: FileHistorySnapshot[] 306: attributionSnapshots?: AttributionSnapshotMessage[] 307: contentReplacements?: ContentReplacementRecord[] 308: contextCollapseCommits?: ContextCollapseCommitEntry[] 309: contextCollapseSnapshot?: ContextCollapseSnapshotEntry 310: sessionId: UUID | undefined 311: agentName?: string 312: agentColor?: string 313: agentSetting?: string 314: customTitle?: string 315: tag?: string 316: mode?: 'coordinator' | 'normal' 317: worktreeSession?: PersistedWorktreeSession | null 318: prNumber?: number 319: prUrl?: string 320: prRepository?: string 321: fullPath?: string 322: } | null> { 323: try { 324: let log: LogOption | null = null 325: let messages: Message[] | null = null 326: let sessionId: UUID | undefined 327: if (source === undefined) { 328: const logsPromise = loadMessageLogs() 329: let skip = new Set<string>() 330: if (feature('BG_SESSIONS')) { 331: try { 332: const { listAllLiveSessions } = await import('./udsClient.js') 333: const live = await listAllLiveSessions() 334: skip = new Set( 335: live.flatMap(s => 336: s.kind && s.kind !== 'interactive' && s.sessionId 337: ? [s.sessionId] 338: : [], 339: ), 340: ) 341: } catch { 342: } 343: } 344: const logs = await logsPromise 345: log = 346: logs.find(l => { 347: const id = getSessionIdFromLog(l) 348: return !id || !skip.has(id) 349: }) ?? null 350: } else if (sourceJsonlFile) { 351: const loaded = await loadMessagesFromJsonlPath(sourceJsonlFile) 352: messages = loaded.messages 353: sessionId = loaded.sessionId 354: } else if (typeof source === 'string') { 355: log = await getLastSessionLog(source as UUID) 356: sessionId = source as UUID 357: } else { 358: log = source 359: } 360: if (!log && !messages) { 361: return null 362: } 363: if (log) { 364: if (isLiteLog(log)) { 365: log = await loadFullLog(log) 366: } 367: if (!sessionId) { 368: sessionId = getSessionIdFromLog(log) as UUID 369: } 370: if (sessionId) { 371: await copyPlanForResume(log, asSessionId(sessionId)) 372: } 373: void copyFileHistoryForResume(log) 374: messages = log.messages 375: checkResumeConsistency(messages) 376: } 377: restoreSkillStateFromMessages(messages!) 378: const deserialized = deserializeMessagesWithInterruptDetection(messages!) 379: messages = deserialized.messages 380: const hookMessages = await processSessionStartHooks('resume', { sessionId }) 381: messages.push(...hookMessages) 382: return { 383: messages, 384: turnInterruptionState: deserialized.turnInterruptionState, 385: fileHistorySnapshots: log?.fileHistorySnapshots, 386: attributionSnapshots: log?.attributionSnapshots, 387: contentReplacements: log?.contentReplacements, 388: contextCollapseCommits: log?.contextCollapseCommits, 389: contextCollapseSnapshot: log?.contextCollapseSnapshot, 390: sessionId, 391: agentName: log?.agentName, 392: agentColor: log?.agentColor, 393: agentSetting: log?.agentSetting, 394: customTitle: log?.customTitle, 395: tag: log?.tag, 396: mode: log?.mode, 397: worktreeSession: log?.worktreeSession, 398: prNumber: log?.prNumber, 399: prUrl: log?.prUrl, 400: prRepository: log?.prRepository, 401: fullPath: log?.fullPath, 402: } 403: } catch (error) { 404: logError(error as Error) 405: throw error 406: } 407: }

File: src/utils/cron.ts

typescript 1: export type CronFields = { 2: minute: number[] 3: hour: number[] 4: dayOfMonth: number[] 5: month: number[] 6: dayOfWeek: number[] 7: } 8: type FieldRange = { min: number; max: number } 9: const FIELD_RANGES: FieldRange[] = [ 10: { min: 0, max: 59 }, 11: { min: 0, max: 23 }, 12: { min: 1, max: 31 }, 13: { min: 1, max: 12 }, 14: { min: 0, max: 6 }, 15: ] 16: function expandField(field: string, range: FieldRange): number[] | null { 17: const { min, max } = range 18: const out = new Set<number>() 19: for (const part of field.split(',')) { 20: const stepMatch = part.match(/^\*(?:\/(\d+))?$/) 21: if (stepMatch) { 22: const step = stepMatch[1] ? parseInt(stepMatch[1], 10) : 1 23: if (step < 1) return null 24: for (let i = min; i <= max; i += step) out.add(i) 25: continue 26: } 27: const rangeMatch = part.match(/^(\d+)-(\d+)(?:\/(\d+))?$/) 28: if (rangeMatch) { 29: const lo = parseInt(rangeMatch[1]!, 10) 30: const hi = parseInt(rangeMatch[2]!, 10) 31: const step = rangeMatch[3] ? parseInt(rangeMatch[3], 10) : 1 32: const isDow = min === 0 && max === 6 33: const effMax = isDow ? 7 : max 34: if (lo > hi || step < 1 || lo < min || hi > effMax) return null 35: for (let i = lo; i <= hi; i += step) { 36: out.add(isDow && i === 7 ? 0 : i) 37: } 38: continue 39: } 40: const singleMatch = part.match(/^\d+$/) 41: if (singleMatch) { 42: let n = parseInt(part, 10) 43: if (min === 0 && max === 6 && n === 7) n = 0 44: if (n < min || n > max) return null 45: out.add(n) 46: continue 47: } 48: return null 49: } 50: if (out.size === 0) return null 51: return Array.from(out).sort((a, b) => a - b) 52: } 53: export function parseCronExpression(expr: string): CronFields | null { 54: const parts = expr.trim().split(/\s+/) 55: if (parts.length !== 5) return null 56: const expanded: number[][] = [] 57: for (let i = 0; i < 5; i++) { 58: const result = expandField(parts[i]!, FIELD_RANGES[i]!) 59: if (!result) return null 60: expanded.push(result) 61: } 62: return { 63: minute: expanded[0]!, 64: hour: expanded[1]!, 65: dayOfMonth: expanded[2]!, 66: month: expanded[3]!, 67: dayOfWeek: expanded[4]!, 68: } 69: } 70: export function computeNextCronRun( 71: fields: CronFields, 72: from: Date, 73: ): Date | null { 74: const minuteSet = new Set(fields.minute) 75: const hourSet = new Set(fields.hour) 76: const domSet = new Set(fields.dayOfMonth) 77: const monthSet = new Set(fields.month) 78: const dowSet = new Set(fields.dayOfWeek) 79: const domWild = fields.dayOfMonth.length === 31 80: const dowWild = fields.dayOfWeek.length === 7 81: const t = new Date(from.getTime()) 82: t.setSeconds(0, 0) 83: t.setMinutes(t.getMinutes() + 1) 84: const maxIter = 366 * 24 * 60 85: for (let i = 0; i < maxIter; i++) { 86: const month = t.getMonth() + 1 87: if (!monthSet.has(month)) { 88: t.setMonth(t.getMonth() + 1, 1) 89: t.setHours(0, 0, 0, 0) 90: continue 91: } 92: const dom = t.getDate() 93: const dow = t.getDay() 94: const dayMatches = 95: domWild && dowWild 96: ? true 97: : domWild 98: ? dowSet.has(dow) 99: : dowWild 100: ? domSet.has(dom) 101: : domSet.has(dom) || dowSet.has(dow) 102: if (!dayMatches) { 103: t.setDate(t.getDate() + 1) 104: t.setHours(0, 0, 0, 0) 105: continue 106: } 107: if (!hourSet.has(t.getHours())) { 108: t.setHours(t.getHours() + 1, 0, 0, 0) 109: continue 110: } 111: if (!minuteSet.has(t.getMinutes())) { 112: t.setMinutes(t.getMinutes() + 1) 113: continue 114: } 115: return t 116: } 117: return null 118: } 119: const DAY_NAMES = [ 120: 'Sunday', 121: 'Monday', 122: 'Tuesday', 123: 'Wednesday', 124: 'Thursday', 125: 'Friday', 126: 'Saturday', 127: ] 128: function formatLocalTime(minute: number, hour: number): string { 129: const d = new Date(2000, 0, 1, hour, minute) 130: return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) 131: } 132: function formatUtcTimeAsLocal(minute: number, hour: number): string { 133: const d = new Date() 134: d.setUTCHours(hour, minute, 0, 0) 135: return d.toLocaleTimeString('en-US', { 136: hour: 'numeric', 137: minute: '2-digit', 138: timeZoneName: 'short', 139: }) 140: } 141: export function cronToHuman(cron: string, opts?: { utc?: boolean }): string { 142: const utc = opts?.utc ?? false 143: const parts = cron.trim().split(/\s+/) 144: if (parts.length !== 5) return cron 145: const [minute, hour, dayOfMonth, month, dayOfWeek] = parts as [ 146: string, 147: string, 148: string, 149: string, 150: string, 151: ] 152: const everyMinMatch = minute.match(/^\*\/(\d+)$/) 153: if ( 154: everyMinMatch && 155: hour === '*' && 156: dayOfMonth === '*' && 157: month === '*' && 158: dayOfWeek === '*' 159: ) { 160: const n = parseInt(everyMinMatch[1]!, 10) 161: return n === 1 ? 'Every minute' : `Every ${n} minutes` 162: } 163: if ( 164: minute.match(/^\d+$/) && 165: hour === '*' && 166: dayOfMonth === '*' && 167: month === '*' && 168: dayOfWeek === '*' 169: ) { 170: const m = parseInt(minute, 10) 171: if (m === 0) return 'Every hour' 172: return `Every hour at :${m.toString().padStart(2, '0')}` 173: } 174: const everyHourMatch = hour.match(/^\*\/(\d+)$/) 175: if ( 176: minute.match(/^\d+$/) && 177: everyHourMatch && 178: dayOfMonth === '*' && 179: month === '*' && 180: dayOfWeek === '*' 181: ) { 182: const n = parseInt(everyHourMatch[1]!, 10) 183: const m = parseInt(minute, 10) 184: const suffix = m === 0 ? '' : ` at :${m.toString().padStart(2, '0')}` 185: return n === 1 ? `Every hour${suffix}` : `Every ${n} hours${suffix}` 186: } 187: // --- Remaining cases reference hour+minute: branch on utc ---------------- 188: if (!minute.match(/^\d+$/) || !hour.match(/^\d+$/)) return cron 189: const m = parseInt(minute, 10) 190: const h = parseInt(hour, 10) 191: const fmtTime = utc ? formatUtcTimeAsLocal : formatLocalTime 192: // Daily at specific time: M H * * * 193: if (dayOfMonth === '*' && month === '*' && dayOfWeek === '*') { 194: return `Every day at ${fmtTime(m, h)}` 195: } 196: // Specific day of week: M H * * D 197: if (dayOfMonth === '*' && month === '*' && dayOfWeek.match(/^\d$/)) { 198: const dayIndex = parseInt(dayOfWeek, 10) % 7 // normalize 7 (Sunday alias) -> 0 199: let dayName: string | undefined 200: if (utc) { 201: // UTC day+time may land on a different local day (midnight crossing). 202: // Compute the actual local weekday by constructing the UTC instant. 203: const ref = new Date() 204: const daysToAdd = (dayIndex - ref.getUTCDay() + 7) % 7 205: ref.setUTCDate(ref.getUTCDate() + daysToAdd) 206: ref.setUTCHours(h, m, 0, 0) 207: dayName = DAY_NAMES[ref.getDay()] 208: } else { 209: dayName = DAY_NAMES[dayIndex] 210: } 211: if (dayName) return `Every ${dayName} at ${fmtTime(m, h)}` 212: } 213: // Weekdays: M H * * 1-5 214: if (dayOfMonth === '*' && month === '*' && dayOfWeek === '1-5') { 215: return `Weekdays at ${fmtTime(m, h)}` 216: } 217: return cron 218: }

File: src/utils/cronJitterConfig.ts

typescript 1: import { z } from 'zod/v4' 2: import { getFeatureValue_CACHED_WITH_REFRESH } from '../services/analytics/growthbook.js' 3: import { 4: type CronJitterConfig, 5: DEFAULT_CRON_JITTER_CONFIG, 6: } from './cronTasks.js' 7: import { lazySchema } from './lazySchema.js' 8: const JITTER_CONFIG_REFRESH_MS = 60 * 1000 9: const HALF_HOUR_MS = 30 * 60 * 1000 10: const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 11: const cronJitterConfigSchema = lazySchema(() => 12: z 13: .object({ 14: recurringFrac: z.number().min(0).max(1), 15: recurringCapMs: z.number().int().min(0).max(HALF_HOUR_MS), 16: oneShotMaxMs: z.number().int().min(0).max(HALF_HOUR_MS), 17: oneShotFloorMs: z.number().int().min(0).max(HALF_HOUR_MS), 18: oneShotMinuteMod: z.number().int().min(1).max(60), 19: recurringMaxAgeMs: z 20: .number() 21: .int() 22: .min(0) 23: .max(THIRTY_DAYS_MS) 24: .default(DEFAULT_CRON_JITTER_CONFIG.recurringMaxAgeMs), 25: }) 26: .refine(c => c.oneShotFloorMs <= c.oneShotMaxMs), 27: ) 28: export function getCronJitterConfig(): CronJitterConfig { 29: const raw = getFeatureValue_CACHED_WITH_REFRESH<unknown>( 30: 'tengu_kairos_cron_config', 31: DEFAULT_CRON_JITTER_CONFIG, 32: JITTER_CONFIG_REFRESH_MS, 33: ) 34: const parsed = cronJitterConfigSchema().safeParse(raw) 35: return parsed.success ? parsed.data : DEFAULT_CRON_JITTER_CONFIG 36: }

File: src/utils/cronScheduler.ts

typescript 1: import type { FSWatcher } from 'chokidar' 2: import { 3: getScheduledTasksEnabled, 4: getSessionCronTasks, 5: removeSessionCronTasks, 6: setScheduledTasksEnabled, 7: } from '../bootstrap/state.js' 8: import { 9: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 10: logEvent, 11: } from '../services/analytics/index.js' 12: import { cronToHuman } from './cron.js' 13: import { 14: type CronJitterConfig, 15: type CronTask, 16: DEFAULT_CRON_JITTER_CONFIG, 17: findMissedTasks, 18: getCronFilePath, 19: hasCronTasksSync, 20: jitteredNextCronRunMs, 21: markCronTasksFired, 22: oneShotJitteredNextCronRunMs, 23: readCronTasks, 24: removeCronTasks, 25: } from './cronTasks.js' 26: import { 27: releaseSchedulerLock, 28: tryAcquireSchedulerLock, 29: } from './cronTasksLock.js' 30: import { logForDebugging } from './debug.js' 31: const CHECK_INTERVAL_MS = 1000 32: const FILE_STABILITY_MS = 300 33: const LOCK_PROBE_INTERVAL_MS = 5000 34: export function isRecurringTaskAged( 35: t: CronTask, 36: nowMs: number, 37: maxAgeMs: number, 38: ): boolean { 39: if (maxAgeMs === 0) return false 40: return Boolean(t.recurring && !t.permanent && nowMs - t.createdAt >= maxAgeMs) 41: } 42: type CronSchedulerOptions = { 43: onFire: (prompt: string) => void 44: isLoading: () => boolean 45: assistantMode?: boolean 46: onFireTask?: (task: CronTask) => void 47: onMissed?: (tasks: CronTask[]) => void 48: dir?: string 49: lockIdentity?: string 50: getJitterConfig?: () => CronJitterConfig 51: isKilled?: () => boolean 52: filter?: (t: CronTask) => boolean 53: } 54: export type CronScheduler = { 55: start: () => void 56: stop: () => void 57: getNextFireTime: () => number | null 58: } 59: export function createCronScheduler( 60: options: CronSchedulerOptions, 61: ): CronScheduler { 62: const { 63: onFire, 64: isLoading, 65: assistantMode = false, 66: onFireTask, 67: onMissed, 68: dir, 69: lockIdentity, 70: getJitterConfig, 71: isKilled, 72: filter, 73: } = options 74: const lockOpts = dir || lockIdentity ? { dir, lockIdentity } : undefined 75: let tasks: CronTask[] = [] 76: const nextFireAt = new Map<string, number>() 77: const missedAsked = new Set<string>() 78: const inFlight = new Set<string>() 79: let enablePoll: ReturnType<typeof setInterval> | null = null 80: let checkTimer: ReturnType<typeof setInterval> | null = null 81: let lockProbeTimer: ReturnType<typeof setInterval> | null = null 82: let watcher: FSWatcher | null = null 83: let stopped = false 84: let isOwner = false 85: async function load(initial: boolean) { 86: const next = await readCronTasks(dir) 87: if (stopped) return 88: tasks = next 89: if (!initial) return 90: const now = Date.now() 91: const missed = findMissedTasks(next, now).filter( 92: t => !t.recurring && !missedAsked.has(t.id) && (!filter || filter(t)), 93: ) 94: if (missed.length > 0) { 95: for (const t of missed) { 96: missedAsked.add(t.id) 97: nextFireAt.set(t.id, Infinity) 98: } 99: logEvent('tengu_scheduled_task_missed', { 100: count: missed.length, 101: taskIds: missed 102: .map(t => t.id) 103: .join( 104: ',', 105: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 106: }) 107: if (onMissed) { 108: onMissed(missed) 109: } else { 110: onFire(buildMissedTaskNotification(missed)) 111: } 112: void removeCronTasks( 113: missed.map(t => t.id), 114: dir, 115: ).catch(e => 116: logForDebugging(`[ScheduledTasks] failed to remove missed tasks: ${e}`), 117: ) 118: logForDebugging( 119: `[ScheduledTasks] surfaced ${missed.length} missed one-shot task(s)`, 120: ) 121: } 122: } 123: function check() { 124: if (isKilled?.()) return 125: if (isLoading() && !assistantMode) return 126: const now = Date.now() 127: const seen = new Set<string>() 128: const firedFileRecurring: string[] = [] 129: const jitterCfg = getJitterConfig?.() ?? DEFAULT_CRON_JITTER_CONFIG 130: function process(t: CronTask, isSession: boolean) { 131: if (filter && !filter(t)) return 132: seen.add(t.id) 133: if (inFlight.has(t.id)) return 134: let next = nextFireAt.get(t.id) 135: if (next === undefined) { 136: next = t.recurring 137: ? (jitteredNextCronRunMs( 138: t.cron, 139: t.lastFiredAt ?? t.createdAt, 140: t.id, 141: jitterCfg, 142: ) ?? Infinity) 143: : (oneShotJitteredNextCronRunMs( 144: t.cron, 145: t.createdAt, 146: t.id, 147: jitterCfg, 148: ) ?? Infinity) 149: nextFireAt.set(t.id, next) 150: logForDebugging( 151: `[ScheduledTasks] scheduled ${t.id} for ${next === Infinity ? 'never' : new Date(next).toISOString()}`, 152: ) 153: } 154: if (now < next) return 155: logForDebugging( 156: `[ScheduledTasks] firing ${t.id}${t.recurring ? ' (recurring)' : ''}`, 157: ) 158: logEvent('tengu_scheduled_task_fire', { 159: recurring: t.recurring ?? false, 160: taskId: 161: t.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 162: }) 163: if (onFireTask) { 164: onFireTask(t) 165: } else { 166: onFire(t.prompt) 167: } 168: const aged = isRecurringTaskAged(t, now, jitterCfg.recurringMaxAgeMs) 169: if (aged) { 170: const ageHours = Math.floor((now - t.createdAt) / 1000 / 60 / 60) 171: logForDebugging( 172: `[ScheduledTasks] recurring task ${t.id} aged out (${ageHours}h since creation), deleting after final fire`, 173: ) 174: logEvent('tengu_scheduled_task_expired', { 175: taskId: 176: t.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 177: ageHours, 178: }) 179: } 180: if (t.recurring && !aged) { 181: const newNext = 182: jitteredNextCronRunMs(t.cron, now, t.id, jitterCfg) ?? Infinity 183: nextFireAt.set(t.id, newNext) 184: if (!isSession) firedFileRecurring.push(t.id) 185: } else if (isSession) { 186: removeSessionCronTasks([t.id]) 187: nextFireAt.delete(t.id) 188: } else { 189: inFlight.add(t.id) 190: void removeCronTasks([t.id], dir) 191: .catch(e => 192: logForDebugging( 193: `[ScheduledTasks] failed to remove task ${t.id}: ${e}`, 194: ), 195: ) 196: .finally(() => inFlight.delete(t.id)) 197: nextFireAt.delete(t.id) 198: } 199: } 200: if (isOwner) { 201: for (const t of tasks) process(t, false) 202: if (firedFileRecurring.length > 0) { 203: for (const id of firedFileRecurring) inFlight.add(id) 204: void markCronTasksFired(firedFileRecurring, now, dir) 205: .catch(e => 206: logForDebugging( 207: `[ScheduledTasks] failed to persist lastFiredAt: ${e}`, 208: ), 209: ) 210: .finally(() => { 211: for (const id of firedFileRecurring) inFlight.delete(id) 212: }) 213: } 214: } 215: if (dir === undefined) { 216: for (const t of getSessionCronTasks()) process(t, true) 217: } 218: if (seen.size === 0) { 219: nextFireAt.clear() 220: return 221: } 222: for (const id of nextFireAt.keys()) { 223: if (!seen.has(id)) nextFireAt.delete(id) 224: } 225: } 226: async function enable() { 227: if (stopped) return 228: if (enablePoll) { 229: clearInterval(enablePoll) 230: enablePoll = null 231: } 232: const { default: chokidar } = await import('chokidar') 233: if (stopped) return 234: isOwner = await tryAcquireSchedulerLock(lockOpts).catch(() => false) 235: if (stopped) { 236: if (isOwner) { 237: isOwner = false 238: void releaseSchedulerLock(lockOpts) 239: } 240: return 241: } 242: if (!isOwner) { 243: lockProbeTimer = setInterval(() => { 244: void tryAcquireSchedulerLock(lockOpts) 245: .then(owned => { 246: if (stopped) { 247: if (owned) void releaseSchedulerLock(lockOpts) 248: return 249: } 250: if (owned) { 251: isOwner = true 252: if (lockProbeTimer) { 253: clearInterval(lockProbeTimer) 254: lockProbeTimer = null 255: } 256: } 257: }) 258: .catch(e => logForDebugging(String(e), { level: 'error' })) 259: }, LOCK_PROBE_INTERVAL_MS) 260: lockProbeTimer.unref?.() 261: } 262: void load(true) 263: const path = getCronFilePath(dir) 264: watcher = chokidar.watch(path, { 265: persistent: false, 266: ignoreInitial: true, 267: awaitWriteFinish: { stabilityThreshold: FILE_STABILITY_MS }, 268: ignorePermissionErrors: true, 269: }) 270: watcher.on('add', () => void load(false)) 271: watcher.on('change', () => void load(false)) 272: watcher.on('unlink', () => { 273: if (!stopped) { 274: tasks = [] 275: nextFireAt.clear() 276: } 277: }) 278: checkTimer = setInterval(check, CHECK_INTERVAL_MS) 279: checkTimer.unref?.() 280: } 281: return { 282: start() { 283: stopped = false 284: if (dir !== undefined) { 285: logForDebugging( 286: `[ScheduledTasks] scheduler start() — dir=${dir}, hasTasks=${hasCronTasksSync(dir)}`, 287: ) 288: void enable() 289: return 290: } 291: logForDebugging( 292: `[ScheduledTasks] scheduler start() — enabled=${getScheduledTasksEnabled()}, hasTasks=${hasCronTasksSync()}`, 293: ) 294: if ( 295: !getScheduledTasksEnabled() && 296: (assistantMode || hasCronTasksSync()) 297: ) { 298: setScheduledTasksEnabled(true) 299: } 300: if (getScheduledTasksEnabled()) { 301: void enable() 302: return 303: } 304: enablePoll = setInterval( 305: en => { 306: if (getScheduledTasksEnabled()) void en() 307: }, 308: CHECK_INTERVAL_MS, 309: enable, 310: ) 311: enablePoll.unref?.() 312: }, 313: stop() { 314: stopped = true 315: if (enablePoll) { 316: clearInterval(enablePoll) 317: enablePoll = null 318: } 319: if (checkTimer) { 320: clearInterval(checkTimer) 321: checkTimer = null 322: } 323: if (lockProbeTimer) { 324: clearInterval(lockProbeTimer) 325: lockProbeTimer = null 326: } 327: void watcher?.close() 328: watcher = null 329: if (isOwner) { 330: isOwner = false 331: void releaseSchedulerLock(lockOpts) 332: } 333: }, 334: getNextFireTime() { 335: let min = Infinity 336: for (const t of nextFireAt.values()) { 337: if (t < min) min = t 338: } 339: return min === Infinity ? null : min 340: }, 341: } 342: } 343: export function buildMissedTaskNotification(missed: CronTask[]): string { 344: const plural = missed.length > 1 345: const header = 346: `The following one-shot scheduled task${plural ? 's were' : ' was'} missed while Claude was not running. ` + 347: `${plural ? 'They have' : 'It has'} already been removed from .claude/scheduled_tasks.json.\n\n` + 348: `Do NOT execute ${plural ? 'these prompts' : 'this prompt'} yet. ` + 349: `First use the AskUserQuestion tool to ask whether to run ${plural ? 'each one' : 'it'} now. ` + 350: `Only execute if the user confirms.` 351: const blocks = missed.map(t => { 352: const meta = `[${cronToHuman(t.cron)}, created ${new Date(t.createdAt).toLocaleString()}]` 353: const longestRun = (t.prompt.match(/`+/g) ?? []).reduce( 354: (max, run) => Math.max(max, run.length), 355: 0, 356: ) 357: const fence = '`'.repeat(Math.max(3, longestRun + 1)) 358: return `${meta}\n${fence}\n${t.prompt}\n${fence}` 359: }) 360: return `${header}\n\n${blocks.join('\n\n')}` 361: }

File: src/utils/cronTasks.ts

typescript 1: import { randomUUID } from 'crypto' 2: import { readFileSync } from 'fs' 3: import { mkdir, writeFile } from 'fs/promises' 4: import { join } from 'path' 5: import { 6: addSessionCronTask, 7: getProjectRoot, 8: getSessionCronTasks, 9: removeSessionCronTasks, 10: } from '../bootstrap/state.js' 11: import { computeNextCronRun, parseCronExpression } from './cron.js' 12: import { logForDebugging } from './debug.js' 13: import { isFsInaccessible } from './errors.js' 14: import { getFsImplementation } from './fsOperations.js' 15: import { safeParseJSON } from './json.js' 16: import { logError } from './log.js' 17: import { jsonStringify } from './slowOperations.js' 18: export type CronTask = { 19: id: string 20: cron: string 21: prompt: string 22: createdAt: number 23: lastFiredAt?: number 24: recurring?: boolean 25: permanent?: boolean 26: durable?: boolean 27: agentId?: string 28: } 29: type CronFile = { tasks: CronTask[] } 30: const CRON_FILE_REL = join('.claude', 'scheduled_tasks.json') 31: export function getCronFilePath(dir?: string): string { 32: return join(dir ?? getProjectRoot(), CRON_FILE_REL) 33: } 34: export async function readCronTasks(dir?: string): Promise<CronTask[]> { 35: const fs = getFsImplementation() 36: let raw: string 37: try { 38: raw = await fs.readFile(getCronFilePath(dir), { encoding: 'utf-8' }) 39: } catch (e: unknown) { 40: if (isFsInaccessible(e)) return [] 41: logError(e) 42: return [] 43: } 44: const parsed = safeParseJSON(raw, false) 45: if (!parsed || typeof parsed !== 'object') return [] 46: const file = parsed as Partial<CronFile> 47: if (!Array.isArray(file.tasks)) return [] 48: const out: CronTask[] = [] 49: for (const t of file.tasks) { 50: if ( 51: !t || 52: typeof t.id !== 'string' || 53: typeof t.cron !== 'string' || 54: typeof t.prompt !== 'string' || 55: typeof t.createdAt !== 'number' 56: ) { 57: logForDebugging( 58: `[ScheduledTasks] skipping malformed task: ${jsonStringify(t)}`, 59: ) 60: continue 61: } 62: if (!parseCronExpression(t.cron)) { 63: logForDebugging( 64: `[ScheduledTasks] skipping task ${t.id} with invalid cron '${t.cron}'`, 65: ) 66: continue 67: } 68: out.push({ 69: id: t.id, 70: cron: t.cron, 71: prompt: t.prompt, 72: createdAt: t.createdAt, 73: ...(typeof t.lastFiredAt === 'number' 74: ? { lastFiredAt: t.lastFiredAt } 75: : {}), 76: ...(t.recurring ? { recurring: true } : {}), 77: ...(t.permanent ? { permanent: true } : {}), 78: }) 79: } 80: return out 81: } 82: export function hasCronTasksSync(dir?: string): boolean { 83: let raw: string 84: try { 85: raw = readFileSync(getCronFilePath(dir), 'utf-8') 86: } catch { 87: return false 88: } 89: const parsed = safeParseJSON(raw, false) 90: if (!parsed || typeof parsed !== 'object') return false 91: const tasks = (parsed as Partial<CronFile>).tasks 92: return Array.isArray(tasks) && tasks.length > 0 93: } 94: export async function writeCronTasks( 95: tasks: CronTask[], 96: dir?: string, 97: ): Promise<void> { 98: const root = dir ?? getProjectRoot() 99: await mkdir(join(root, '.claude'), { recursive: true }) 100: const body: CronFile = { 101: tasks: tasks.map(({ durable: _durable, ...rest }) => rest), 102: } 103: await writeFile( 104: getCronFilePath(root), 105: jsonStringify(body, null, 2) + '\n', 106: 'utf-8', 107: ) 108: } 109: export async function addCronTask( 110: cron: string, 111: prompt: string, 112: recurring: boolean, 113: durable: boolean, 114: agentId?: string, 115: ): Promise<string> { 116: const id = randomUUID().slice(0, 8) 117: const task = { 118: id, 119: cron, 120: prompt, 121: createdAt: Date.now(), 122: ...(recurring ? { recurring: true } : {}), 123: } 124: if (!durable) { 125: addSessionCronTask({ ...task, ...(agentId ? { agentId } : {}) }) 126: return id 127: } 128: const tasks = await readCronTasks() 129: tasks.push(task) 130: await writeCronTasks(tasks) 131: return id 132: } 133: export async function removeCronTasks( 134: ids: string[], 135: dir?: string, 136: ): Promise<void> { 137: if (ids.length === 0) return 138: if (dir === undefined && removeSessionCronTasks(ids) === ids.length) { 139: return 140: } 141: const idSet = new Set(ids) 142: const tasks = await readCronTasks(dir) 143: const remaining = tasks.filter(t => !idSet.has(t.id)) 144: if (remaining.length === tasks.length) return 145: await writeCronTasks(remaining, dir) 146: } 147: export async function markCronTasksFired( 148: ids: string[], 149: firedAt: number, 150: dir?: string, 151: ): Promise<void> { 152: if (ids.length === 0) return 153: const idSet = new Set(ids) 154: const tasks = await readCronTasks(dir) 155: let changed = false 156: for (const t of tasks) { 157: if (idSet.has(t.id)) { 158: t.lastFiredAt = firedAt 159: changed = true 160: } 161: } 162: if (!changed) return 163: await writeCronTasks(tasks, dir) 164: } 165: export async function listAllCronTasks(dir?: string): Promise<CronTask[]> { 166: const fileTasks = await readCronTasks(dir) 167: if (dir !== undefined) return fileTasks 168: const sessionTasks = getSessionCronTasks().map(t => ({ 169: ...t, 170: durable: false as const, 171: })) 172: return [...fileTasks, ...sessionTasks] 173: } 174: export function nextCronRunMs(cron: string, fromMs: number): number | null { 175: const fields = parseCronExpression(cron) 176: if (!fields) return null 177: const next = computeNextCronRun(fields, new Date(fromMs)) 178: return next ? next.getTime() : null 179: } 180: export type CronJitterConfig = { 181: recurringFrac: number 182: recurringCapMs: number 183: oneShotMaxMs: number 184: oneShotFloorMs: number 185: oneShotMinuteMod: number 186: recurringMaxAgeMs: number 187: } 188: export const DEFAULT_CRON_JITTER_CONFIG: CronJitterConfig = { 189: recurringFrac: 0.1, 190: recurringCapMs: 15 * 60 * 1000, 191: oneShotMaxMs: 90 * 1000, 192: oneShotFloorMs: 0, 193: oneShotMinuteMod: 30, 194: recurringMaxAgeMs: 7 * 24 * 60 * 60 * 1000, 195: } 196: function jitterFrac(taskId: string): number { 197: const frac = parseInt(taskId.slice(0, 8), 16) / 0x1_0000_0000 198: return Number.isFinite(frac) ? frac : 0 199: } 200: export function jitteredNextCronRunMs( 201: cron: string, 202: fromMs: number, 203: taskId: string, 204: cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG, 205: ): number | null { 206: const t1 = nextCronRunMs(cron, fromMs) 207: if (t1 === null) return null 208: const t2 = nextCronRunMs(cron, t1) 209: if (t2 === null) return t1 210: const jitter = Math.min( 211: jitterFrac(taskId) * cfg.recurringFrac * (t2 - t1), 212: cfg.recurringCapMs, 213: ) 214: return t1 + jitter 215: } 216: export function oneShotJitteredNextCronRunMs( 217: cron: string, 218: fromMs: number, 219: taskId: string, 220: cfg: CronJitterConfig = DEFAULT_CRON_JITTER_CONFIG, 221: ): number | null { 222: const t1 = nextCronRunMs(cron, fromMs) 223: if (t1 === null) return null 224: if (new Date(t1).getMinutes() % cfg.oneShotMinuteMod !== 0) return t1 225: const lead = 226: cfg.oneShotFloorMs + 227: jitterFrac(taskId) * (cfg.oneShotMaxMs - cfg.oneShotFloorMs) 228: return Math.max(t1 - lead, fromMs) 229: } 230: export function findMissedTasks(tasks: CronTask[], nowMs: number): CronTask[] { 231: return tasks.filter(t => { 232: const next = nextCronRunMs(t.cron, t.createdAt) 233: return next !== null && next < nowMs 234: }) 235: }

File: src/utils/cronTasksLock.ts

typescript 1: import { mkdir, readFile, unlink, writeFile } from 'fs/promises' 2: import { dirname, join } from 'path' 3: import { z } from 'zod/v4' 4: import { getProjectRoot, getSessionId } from '../bootstrap/state.js' 5: import { registerCleanup } from './cleanupRegistry.js' 6: import { logForDebugging } from './debug.js' 7: import { getErrnoCode } from './errors.js' 8: import { isProcessRunning } from './genericProcessUtils.js' 9: import { safeParseJSON } from './json.js' 10: import { lazySchema } from './lazySchema.js' 11: import { jsonStringify } from './slowOperations.js' 12: const LOCK_FILE_REL = join('.claude', 'scheduled_tasks.lock') 13: const schedulerLockSchema = lazySchema(() => 14: z.object({ 15: sessionId: z.string(), 16: pid: z.number(), 17: acquiredAt: z.number(), 18: }), 19: ) 20: type SchedulerLock = z.infer<ReturnType<typeof schedulerLockSchema>> 21: export type SchedulerLockOptions = { 22: dir?: string 23: lockIdentity?: string 24: } 25: let unregisterCleanup: (() => void) | undefined 26: let lastBlockedBy: string | undefined 27: function getLockPath(dir?: string): string { 28: return join(dir ?? getProjectRoot(), LOCK_FILE_REL) 29: } 30: async function readLock(dir?: string): Promise<SchedulerLock | undefined> { 31: let raw: string 32: try { 33: raw = await readFile(getLockPath(dir), 'utf8') 34: } catch { 35: return undefined 36: } 37: const result = schedulerLockSchema().safeParse(safeParseJSON(raw, false)) 38: return result.success ? result.data : undefined 39: } 40: async function tryCreateExclusive( 41: lock: SchedulerLock, 42: dir?: string, 43: ): Promise<boolean> { 44: const path = getLockPath(dir) 45: const body = jsonStringify(lock) 46: try { 47: await writeFile(path, body, { flag: 'wx' }) 48: return true 49: } catch (e: unknown) { 50: const code = getErrnoCode(e) 51: if (code === 'EEXIST') return false 52: if (code === 'ENOENT') { 53: await mkdir(dirname(path), { recursive: true }) 54: try { 55: await writeFile(path, body, { flag: 'wx' }) 56: return true 57: } catch (retryErr: unknown) { 58: if (getErrnoCode(retryErr) === 'EEXIST') return false 59: throw retryErr 60: } 61: } 62: throw e 63: } 64: } 65: function registerLockCleanup(opts?: SchedulerLockOptions): void { 66: unregisterCleanup?.() 67: unregisterCleanup = registerCleanup(async () => { 68: await releaseSchedulerLock(opts) 69: }) 70: } 71: export async function tryAcquireSchedulerLock( 72: opts?: SchedulerLockOptions, 73: ): Promise<boolean> { 74: const dir = opts?.dir 75: const sessionId = opts?.lockIdentity ?? getSessionId() 76: const lock: SchedulerLock = { 77: sessionId, 78: pid: process.pid, 79: acquiredAt: Date.now(), 80: } 81: if (await tryCreateExclusive(lock, dir)) { 82: lastBlockedBy = undefined 83: registerLockCleanup(opts) 84: logForDebugging( 85: `[ScheduledTasks] acquired scheduler lock (PID ${process.pid})`, 86: ) 87: return true 88: } 89: const existing = await readLock(dir) 90: if (existing?.sessionId === sessionId) { 91: if (existing.pid !== process.pid) { 92: await writeFile(getLockPath(dir), jsonStringify(lock)) 93: registerLockCleanup(opts) 94: } 95: return true 96: } 97: if (existing && isProcessRunning(existing.pid)) { 98: if (lastBlockedBy !== existing.sessionId) { 99: lastBlockedBy = existing.sessionId 100: logForDebugging( 101: `[ScheduledTasks] scheduler lock held by session ${existing.sessionId} (PID ${existing.pid})`, 102: ) 103: } 104: return false 105: } 106: if (existing) { 107: logForDebugging( 108: `[ScheduledTasks] recovering stale scheduler lock from PID ${existing.pid}`, 109: ) 110: } 111: await unlink(getLockPath(dir)).catch(() => {}) 112: if (await tryCreateExclusive(lock, dir)) { 113: lastBlockedBy = undefined 114: registerLockCleanup(opts) 115: return true 116: } 117: return false 118: } 119: export async function releaseSchedulerLock( 120: opts?: SchedulerLockOptions, 121: ): Promise<void> { 122: unregisterCleanup?.() 123: unregisterCleanup = undefined 124: lastBlockedBy = undefined 125: const dir = opts?.dir 126: const sessionId = opts?.lockIdentity ?? getSessionId() 127: const existing = await readLock(dir) 128: if (!existing || existing.sessionId !== sessionId) return 129: try { 130: await unlink(getLockPath(dir)) 131: logForDebugging('[ScheduledTasks] released scheduler lock') 132: } catch { 133: } 134: }

File: src/utils/crossProjectResume.ts

typescript 1: import { sep } from 'path' 2: import { getOriginalCwd } from '../bootstrap/state.js' 3: import type { LogOption } from '../types/logs.js' 4: import { quote } from './bash/shellQuote.js' 5: import { getSessionIdFromLog } from './sessionStorage.js' 6: export type CrossProjectResumeResult = 7: | { 8: isCrossProject: false 9: } 10: | { 11: isCrossProject: true 12: isSameRepoWorktree: true 13: projectPath: string 14: } 15: | { 16: isCrossProject: true 17: isSameRepoWorktree: false 18: command: string 19: projectPath: string 20: } 21: export function checkCrossProjectResume( 22: log: LogOption, 23: showAllProjects: boolean, 24: worktreePaths: string[], 25: ): CrossProjectResumeResult { 26: const currentCwd = getOriginalCwd() 27: if (!showAllProjects || !log.projectPath || log.projectPath === currentCwd) { 28: return { isCrossProject: false } 29: } 30: if (process.env.USER_TYPE !== 'ant') { 31: const sessionId = getSessionIdFromLog(log) 32: const command = `cd ${quote([log.projectPath])} && claude --resume ${sessionId}` 33: return { 34: isCrossProject: true, 35: isSameRepoWorktree: false, 36: command, 37: projectPath: log.projectPath, 38: } 39: } 40: const isSameRepo = worktreePaths.some( 41: wt => log.projectPath === wt || log.projectPath!.startsWith(wt + sep), 42: ) 43: if (isSameRepo) { 44: return { 45: isCrossProject: true, 46: isSameRepoWorktree: true, 47: projectPath: log.projectPath, 48: } 49: } 50: const sessionId = getSessionIdFromLog(log) 51: const command = `cd ${quote([log.projectPath])} && claude --resume ${sessionId}` 52: return { 53: isCrossProject: true, 54: isSameRepoWorktree: false, 55: command, 56: projectPath: log.projectPath, 57: } 58: }

File: src/utils/crypto.ts

typescript 1: import { randomUUID } from 'crypto' 2: export { randomUUID }

File: src/utils/Cursor.ts

typescript 1: import { stringWidth } from '../ink/stringWidth.js' 2: import { wrapAnsi } from '../ink/wrapAnsi.js' 3: import { 4: firstGrapheme, 5: getGraphemeSegmenter, 6: getWordSegmenter, 7: } from './intl.js' 8: const KILL_RING_MAX_SIZE = 10 9: let killRing: string[] = [] 10: let killRingIndex = 0 11: let lastActionWasKill = false 12: let lastYankStart = 0 13: let lastYankLength = 0 14: let lastActionWasYank = false 15: export function pushToKillRing( 16: text: string, 17: direction: 'prepend' | 'append' = 'append', 18: ): void { 19: if (text.length > 0) { 20: if (lastActionWasKill && killRing.length > 0) { 21: if (direction === 'prepend') { 22: killRing[0] = text + killRing[0] 23: } else { 24: killRing[0] = killRing[0] + text 25: } 26: } else { 27: killRing.unshift(text) 28: if (killRing.length > KILL_RING_MAX_SIZE) { 29: killRing.pop() 30: } 31: } 32: lastActionWasKill = true 33: lastActionWasYank = false 34: } 35: } 36: export function getLastKill(): string { 37: return killRing[0] ?? '' 38: } 39: export function getKillRingItem(index: number): string { 40: if (killRing.length === 0) return '' 41: const normalizedIndex = 42: ((index % killRing.length) + killRing.length) % killRing.length 43: return killRing[normalizedIndex] ?? '' 44: } 45: export function getKillRingSize(): number { 46: return killRing.length 47: } 48: export function clearKillRing(): void { 49: killRing = [] 50: killRingIndex = 0 51: lastActionWasKill = false 52: lastActionWasYank = false 53: lastYankStart = 0 54: lastYankLength = 0 55: } 56: export function resetKillAccumulation(): void { 57: lastActionWasKill = false 58: } 59: // Yank tracking for yank-pop 60: export function recordYank(start: number, length: number): void { 61: lastYankStart = start 62: lastYankLength = length 63: lastActionWasYank = true 64: killRingIndex = 0 65: } 66: export function canYankPop(): boolean { 67: return lastActionWasYank && killRing.length > 1 68: } 69: export function yankPop(): { 70: text: string 71: start: number 72: length: number 73: } | null { 74: if (!lastActionWasYank || killRing.length <= 1) { 75: return null 76: } 77: // Cycle to next item in kill ring 78: killRingIndex = (killRingIndex + 1) % killRing.length 79: const text = killRing[killRingIndex] ?? '' 80: return { text, start: lastYankStart, length: lastYankLength } 81: } 82: export function updateYankLength(length: number): void { 83: lastYankLength = length 84: } 85: export function resetYankState(): void { 86: lastActionWasYank = false 87: } 88: /** 89: * Text Processing Flow for Unicode Normalization: 90: * 91: * User Input (raw text, potentially mixed NFD/NFC) 92: * ↓ 93: * MeasuredText (normalizes to NFC + builds grapheme info) 94: * ↓ 95: * All cursor operations use normalized text/offsets 96: * ↓ 97: * Display uses normalized text from wrappedLines 98: * 99: * This flow ensures consistent Unicode handling: 100: * - NFD/NFC normalization differences don't break cursor movement 101: * - Grapheme clusters (like 👨‍👩‍👧‍👦) are treated as single units 102: * - Display width calculations are accurate for CJK characters 103: * 104: * RULE: Once text enters MeasuredText, all operations 105: * work on the normalized version. 106: */ 107: export const VIM_WORD_CHAR_REGEX = /^[\p{L}\p{N}\p{M}_]$/u 108: export const WHITESPACE_REGEX = /\s/ 109: export const isVimWordChar = (ch: string): boolean => 110: VIM_WORD_CHAR_REGEX.test(ch) 111: export const isVimWhitespace = (ch: string): boolean => 112: WHITESPACE_REGEX.test(ch) 113: export const isVimPunctuation = (ch: string): boolean => 114: ch.length > 0 && !isVimWhitespace(ch) && !isVimWordChar(ch) 115: type WrappedText = string[] 116: type Position = { 117: line: number 118: column: number 119: } 120: export class Cursor { 121: readonly offset: number 122: constructor( 123: readonly measuredText: MeasuredText, 124: offset: number = 0, 125: readonly selection: number = 0, 126: ) { 127: this.offset = Math.max(0, Math.min(this.text.length, offset)) 128: } 129: static fromText( 130: text: string, 131: columns: number, 132: offset: number = 0, 133: selection: number = 0, 134: ): Cursor { 135: return new Cursor(new MeasuredText(text, columns - 1), offset, selection) 136: } 137: getViewportStartLine(maxVisibleLines?: number): number { 138: if (maxVisibleLines === undefined || maxVisibleLines <= 0) return 0 139: const { line } = this.getPosition() 140: const allLines = this.measuredText.getWrappedText() 141: if (allLines.length <= maxVisibleLines) return 0 142: const half = Math.floor(maxVisibleLines / 2) 143: let startLine = Math.max(0, line - half) 144: const endLine = Math.min(allLines.length, startLine + maxVisibleLines) 145: if (endLine - startLine < maxVisibleLines) { 146: startLine = Math.max(0, endLine - maxVisibleLines) 147: } 148: return startLine 149: } 150: getViewportCharOffset(maxVisibleLines?: number): number { 151: const startLine = this.getViewportStartLine(maxVisibleLines) 152: if (startLine === 0) return 0 153: const wrappedLines = this.measuredText.getWrappedLines() 154: return wrappedLines[startLine]?.startOffset ?? 0 155: } 156: getViewportCharEnd(maxVisibleLines?: number): number { 157: const startLine = this.getViewportStartLine(maxVisibleLines) 158: const allLines = this.measuredText.getWrappedLines() 159: if (maxVisibleLines === undefined || maxVisibleLines <= 0) 160: return this.text.length 161: const endLine = Math.min(allLines.length, startLine + maxVisibleLines) 162: if (endLine >= allLines.length) return this.text.length 163: return allLines[endLine]?.startOffset ?? this.text.length 164: } 165: render( 166: cursorChar: string, 167: mask: string, 168: invert: (text: string) => string, 169: ghostText?: { text: string; dim: (text: string) => string }, 170: maxVisibleLines?: number, 171: ) { 172: const { line, column } = this.getPosition() 173: const allLines = this.measuredText.getWrappedText() 174: const startLine = this.getViewportStartLine(maxVisibleLines) 175: const endLine = 176: maxVisibleLines !== undefined && maxVisibleLines > 0 177: ? Math.min(allLines.length, startLine + maxVisibleLines) 178: : allLines.length 179: return allLines 180: .slice(startLine, endLine) 181: .map((text, i) => { 182: const currentLine = i + startLine 183: let displayText = text 184: if (mask) { 185: const graphemes = Array.from(getGraphemeSegmenter().segment(text)) 186: if (currentLine === allLines.length - 1) { 187: const visibleCount = Math.min(6, graphemes.length) 188: const maskCount = graphemes.length - visibleCount 189: const splitOffset = 190: graphemes.length > visibleCount ? graphemes[maskCount]!.index : 0 191: displayText = mask.repeat(maskCount) + text.slice(splitOffset) 192: } else { 193: displayText = mask.repeat(graphemes.length) 194: } 195: } 196: if (line !== currentLine) return displayText.trimEnd() 197: let beforeCursor = '' 198: let atCursor = cursorChar 199: let afterCursor = '' 200: let currentWidth = 0 201: let cursorFound = false 202: for (const { segment } of getGraphemeSegmenter().segment(displayText)) { 203: if (cursorFound) { 204: afterCursor += segment 205: continue 206: } 207: const nextWidth = currentWidth + stringWidth(segment) 208: if (nextWidth > column) { 209: atCursor = segment 210: cursorFound = true 211: } else { 212: currentWidth = nextWidth 213: beforeCursor += segment 214: } 215: } 216: // Only invert the cursor if we have a cursor character to show 217: // When ghost text is present and cursor is at end, show first ghost char in cursor 218: let renderedCursor: string 219: let ghostSuffix = '' 220: if ( 221: ghostText && 222: currentLine === allLines.length - 1 && 223: this.isAtEnd() && 224: ghostText.text.length > 0 225: ) { 226: // First ghost character goes in the inverted cursor (grapheme-safe) 227: const firstGhostChar = 228: firstGrapheme(ghostText.text) || ghostText.text[0]! 229: renderedCursor = cursorChar ? invert(firstGhostChar) : firstGhostChar 230: // Rest of ghost text is dimmed after cursor 231: const ghostRest = ghostText.text.slice(firstGhostChar.length) 232: if (ghostRest.length > 0) { 233: ghostSuffix = ghostText.dim(ghostRest) 234: } 235: } else { 236: renderedCursor = cursorChar ? invert(atCursor) : atCursor 237: } 238: return ( 239: beforeCursor + renderedCursor + ghostSuffix + afterCursor.trimEnd() 240: ) 241: }) 242: .join('\n') 243: } 244: left(): Cursor { 245: if (this.offset === 0) return this 246: const chip = this.imageRefEndingAt(this.offset) 247: if (chip) return new Cursor(this.measuredText, chip.start) 248: const prevOffset = this.measuredText.prevOffset(this.offset) 249: return new Cursor(this.measuredText, prevOffset) 250: } 251: right(): Cursor { 252: if (this.offset >= this.text.length) return this 253: const chip = this.imageRefStartingAt(this.offset) 254: if (chip) return new Cursor(this.measuredText, chip.end) 255: const nextOffset = this.measuredText.nextOffset(this.offset) 256: return new Cursor(this.measuredText, Math.min(nextOffset, this.text.length)) 257: } 258: imageRefEndingAt(offset: number): { start: number; end: number } | null { 259: const m = this.text.slice(0, offset).match(/\[Image #\d+\]$/) 260: return m ? { start: offset - m[0].length, end: offset } : null 261: } 262: imageRefStartingAt(offset: number): { start: number; end: number } | null { 263: const m = this.text.slice(offset).match(/^\[Image #\d+\]/) 264: return m ? { start: offset, end: offset + m[0].length } : null 265: } 266: snapOutOfImageRef(offset: number, toward: 'start' | 'end'): number { 267: const re = /\[Image #\d+\]/g 268: let m 269: while ((m = re.exec(this.text)) !== null) { 270: const start = m.index 271: const end = start + m[0].length 272: if (offset > start && offset < end) { 273: return toward === 'start' ? start : end 274: } 275: } 276: return offset 277: } 278: up(): Cursor { 279: const { line, column } = this.getPosition() 280: if (line === 0) { 281: return this 282: } 283: const prevLine = this.measuredText.getWrappedText()[line - 1] 284: if (prevLine === undefined) { 285: return this 286: } 287: const prevLineDisplayWidth = stringWidth(prevLine) 288: if (column > prevLineDisplayWidth) { 289: const newOffset = this.getOffset({ 290: line: line - 1, 291: column: prevLineDisplayWidth, 292: }) 293: return new Cursor(this.measuredText, newOffset, 0) 294: } 295: const newOffset = this.getOffset({ line: line - 1, column }) 296: return new Cursor(this.measuredText, newOffset, 0) 297: } 298: down(): Cursor { 299: const { line, column } = this.getPosition() 300: if (line >= this.measuredText.lineCount - 1) { 301: return this 302: } 303: const nextLine = this.measuredText.getWrappedText()[line + 1] 304: if (nextLine === undefined) { 305: return this 306: } 307: const nextLineDisplayWidth = stringWidth(nextLine) 308: if (column > nextLineDisplayWidth) { 309: const newOffset = this.getOffset({ 310: line: line + 1, 311: column: nextLineDisplayWidth, 312: }) 313: return new Cursor(this.measuredText, newOffset, 0) 314: } 315: const newOffset = this.getOffset({ 316: line: line + 1, 317: column, 318: }) 319: return new Cursor(this.measuredText, newOffset, 0) 320: } 321: private startOfCurrentLine(): Cursor { 322: const { line } = this.getPosition() 323: return new Cursor( 324: this.measuredText, 325: this.getOffset({ 326: line, 327: column: 0, 328: }), 329: 0, 330: ) 331: } 332: startOfLine(): Cursor { 333: const { line, column } = this.getPosition() 334: if (column === 0 && line > 0) { 335: return new Cursor( 336: this.measuredText, 337: this.getOffset({ 338: line: line - 1, 339: column: 0, 340: }), 341: 0, 342: ) 343: } 344: return this.startOfCurrentLine() 345: } 346: firstNonBlankInLine(): Cursor { 347: const { line } = this.getPosition() 348: const lineText = this.measuredText.getWrappedText()[line] || '' 349: const match = lineText.match(/^\s*\S/) 350: const column = match?.index ? match.index + match[0].length - 1 : 0 351: const offset = this.getOffset({ line, column }) 352: return new Cursor(this.measuredText, offset, 0) 353: } 354: endOfLine(): Cursor { 355: const { line } = this.getPosition() 356: const column = this.measuredText.getLineLength(line) 357: const offset = this.getOffset({ line, column }) 358: return new Cursor(this.measuredText, offset, 0) 359: } 360: // Helper methods for finding logical line boundaries 361: private findLogicalLineStart(fromOffset: number = this.offset): number { 362: const prevNewline = this.text.lastIndexOf('\n', fromOffset - 1) 363: return prevNewline === -1 ? 0 : prevNewline + 1 364: } 365: private findLogicalLineEnd(fromOffset: number = this.offset): number { 366: const nextNewline = this.text.indexOf('\n', fromOffset) 367: return nextNewline === -1 ? this.text.length : nextNewline 368: } 369: private getLogicalLineBounds(): { start: number; end: number } { 370: return { 371: start: this.findLogicalLineStart(), 372: end: this.findLogicalLineEnd(), 373: } 374: } 375: private createCursorWithColumn( 376: lineStart: number, 377: lineEnd: number, 378: targetColumn: number, 379: ): Cursor { 380: const lineLength = lineEnd - lineStart 381: const clampedColumn = Math.min(targetColumn, lineLength) 382: const rawOffset = lineStart + clampedColumn 383: const offset = this.measuredText.snapToGraphemeBoundary(rawOffset) 384: return new Cursor(this.measuredText, offset, 0) 385: } 386: endOfLogicalLine(): Cursor { 387: return new Cursor(this.measuredText, this.findLogicalLineEnd(), 0) 388: } 389: startOfLogicalLine(): Cursor { 390: return new Cursor(this.measuredText, this.findLogicalLineStart(), 0) 391: } 392: firstNonBlankInLogicalLine(): Cursor { 393: const { start, end } = this.getLogicalLineBounds() 394: const lineText = this.text.slice(start, end) 395: const match = lineText.match(/\S/) 396: const offset = start + (match?.index ?? 0) 397: return new Cursor(this.measuredText, offset, 0) 398: } 399: upLogicalLine(): Cursor { 400: const { start: currentStart } = this.getLogicalLineBounds() 401: if (currentStart === 0) { 402: return new Cursor(this.measuredText, 0, 0) 403: } 404: const currentColumn = this.offset - currentStart 405: const prevLineEnd = currentStart - 1 406: const prevLineStart = this.findLogicalLineStart(prevLineEnd) 407: return this.createCursorWithColumn( 408: prevLineStart, 409: prevLineEnd, 410: currentColumn, 411: ) 412: } 413: downLogicalLine(): Cursor { 414: const { start: currentStart, end: currentEnd } = this.getLogicalLineBounds() 415: if (currentEnd >= this.text.length) { 416: return new Cursor(this.measuredText, this.text.length, 0) 417: } 418: const currentColumn = this.offset - currentStart 419: const nextLineStart = currentEnd + 1 420: const nextLineEnd = this.findLogicalLineEnd(nextLineStart) 421: return this.createCursorWithColumn( 422: nextLineStart, 423: nextLineEnd, 424: currentColumn, 425: ) 426: } 427: nextWord(): Cursor { 428: if (this.isAtEnd()) { 429: return this 430: } 431: const wordBoundaries = this.measuredText.getWordBoundaries() 432: for (const boundary of wordBoundaries) { 433: if (boundary.isWordLike && boundary.start > this.offset) { 434: return new Cursor(this.measuredText, boundary.start) 435: } 436: } 437: return new Cursor(this.measuredText, this.text.length) 438: } 439: endOfWord(): Cursor { 440: if (this.isAtEnd()) { 441: return this 442: } 443: const wordBoundaries = this.measuredText.getWordBoundaries() 444: for (const boundary of wordBoundaries) { 445: if (!boundary.isWordLike) continue 446: if (this.offset >= boundary.start && this.offset < boundary.end - 1) { 447: return new Cursor(this.measuredText, boundary.end - 1) 448: } 449: if (this.offset === boundary.end - 1) { 450: for (const nextBoundary of wordBoundaries) { 451: if (nextBoundary.isWordLike && nextBoundary.start > this.offset) { 452: return new Cursor(this.measuredText, nextBoundary.end - 1) 453: } 454: } 455: return this 456: } 457: } 458: for (const boundary of wordBoundaries) { 459: if (boundary.isWordLike && boundary.start > this.offset) { 460: return new Cursor(this.measuredText, boundary.end - 1) 461: } 462: } 463: return this 464: } 465: prevWord(): Cursor { 466: if (this.isAtStart()) { 467: return this 468: } 469: const wordBoundaries = this.measuredText.getWordBoundaries() 470: let prevWordStart: number | null = null 471: for (const boundary of wordBoundaries) { 472: if (!boundary.isWordLike) continue 473: if (boundary.start < this.offset) { 474: if (this.offset > boundary.start && this.offset <= boundary.end) { 475: return new Cursor(this.measuredText, boundary.start) 476: } 477: prevWordStart = boundary.start 478: } 479: } 480: if (prevWordStart !== null) { 481: return new Cursor(this.measuredText, prevWordStart) 482: } 483: return new Cursor(this.measuredText, 0) 484: } 485: nextVimWord(): Cursor { 486: if (this.isAtEnd()) { 487: return this 488: } 489: let pos = this.offset 490: const advance = (p: number): number => this.measuredText.nextOffset(p) 491: const currentGrapheme = this.graphemeAt(pos) 492: if (!currentGrapheme) { 493: return this 494: } 495: if (isVimWordChar(currentGrapheme)) { 496: while (pos < this.text.length && isVimWordChar(this.graphemeAt(pos))) { 497: pos = advance(pos) 498: } 499: } else if (isVimPunctuation(currentGrapheme)) { 500: while (pos < this.text.length && isVimPunctuation(this.graphemeAt(pos))) { 501: pos = advance(pos) 502: } 503: } 504: while ( 505: pos < this.text.length && 506: WHITESPACE_REGEX.test(this.graphemeAt(pos)) 507: ) { 508: pos = advance(pos) 509: } 510: return new Cursor(this.measuredText, pos) 511: } 512: endOfVimWord(): Cursor { 513: if (this.isAtEnd()) { 514: return this 515: } 516: const text = this.text 517: let pos = this.offset 518: const advance = (p: number): number => this.measuredText.nextOffset(p) 519: if (this.graphemeAt(pos) === '') { 520: return this 521: } 522: pos = advance(pos) 523: while (pos < text.length && WHITESPACE_REGEX.test(this.graphemeAt(pos))) { 524: pos = advance(pos) 525: } 526: if (pos >= text.length) { 527: return new Cursor(this.measuredText, text.length) 528: } 529: const charAtPos = this.graphemeAt(pos) 530: if (isVimWordChar(charAtPos)) { 531: while (pos < text.length) { 532: const nextPos = advance(pos) 533: if (nextPos >= text.length || !isVimWordChar(this.graphemeAt(nextPos))) 534: break 535: pos = nextPos 536: } 537: } else if (isVimPunctuation(charAtPos)) { 538: while (pos < text.length) { 539: const nextPos = advance(pos) 540: if ( 541: nextPos >= text.length || 542: !isVimPunctuation(this.graphemeAt(nextPos)) 543: ) 544: break 545: pos = nextPos 546: } 547: } 548: return new Cursor(this.measuredText, pos) 549: } 550: prevVimWord(): Cursor { 551: if (this.isAtStart()) { 552: return this 553: } 554: let pos = this.offset 555: const retreat = (p: number): number => this.measuredText.prevOffset(p) 556: pos = retreat(pos) 557: while (pos > 0 && WHITESPACE_REGEX.test(this.graphemeAt(pos))) { 558: pos = retreat(pos) 559: } 560: // At position 0 with whitespace means no previous word exists, go to start 561: if (pos === 0 && WHITESPACE_REGEX.test(this.graphemeAt(0))) { 562: return new Cursor(this.measuredText, 0) 563: } 564: const charAtPos = this.graphemeAt(pos) 565: if (isVimWordChar(charAtPos)) { 566: while (pos > 0) { 567: const prevPos = retreat(pos) 568: if (!isVimWordChar(this.graphemeAt(prevPos))) break 569: pos = prevPos 570: } 571: } else if (isVimPunctuation(charAtPos)) { 572: while (pos > 0) { 573: const prevPos = retreat(pos) 574: if (!isVimPunctuation(this.graphemeAt(prevPos))) break 575: pos = prevPos 576: } 577: } 578: return new Cursor(this.measuredText, pos) 579: } 580: nextWORD(): Cursor { 581: // eslint-disable-next-line @typescript-eslint/no-this-alias 582: let nextCursor: Cursor = this 583: // If we're on a non-whitespace character, move to the next whitespace 584: while (!nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) { 585: nextCursor = nextCursor.right() 586: } 587: while (nextCursor.isOverWhitespace() && !nextCursor.isAtEnd()) { 588: nextCursor = nextCursor.right() 589: } 590: return nextCursor 591: } 592: endOfWORD(): Cursor { 593: if (this.isAtEnd()) { 594: return this 595: } 596: let cursor: Cursor = this 597: const atEndOfWORD = 598: !cursor.isOverWhitespace() && 599: (cursor.right().isOverWhitespace() || cursor.right().isAtEnd()) 600: if (atEndOfWORD) { 601: cursor = cursor.right() 602: return cursor.endOfWORD() 603: } 604: if (cursor.isOverWhitespace()) { 605: cursor = cursor.nextWORD() 606: } 607: while (!cursor.right().isOverWhitespace() && !cursor.isAtEnd()) { 608: cursor = cursor.right() 609: } 610: return cursor 611: } 612: prevWORD(): Cursor { 613: let cursor: Cursor = this 614: if (cursor.left().isOverWhitespace()) { 615: cursor = cursor.left() 616: } 617: while (cursor.isOverWhitespace() && !cursor.isAtStart()) { 618: cursor = cursor.left() 619: } 620: if (!cursor.isOverWhitespace()) { 621: while (!cursor.left().isOverWhitespace() && !cursor.isAtStart()) { 622: cursor = cursor.left() 623: } 624: } 625: return cursor 626: } 627: modifyText(end: Cursor, insertString: string = ''): Cursor { 628: const startOffset = this.offset 629: const endOffset = end.offset 630: const newText = 631: this.text.slice(0, startOffset) + 632: insertString + 633: this.text.slice(endOffset) 634: return Cursor.fromText( 635: newText, 636: this.columns, 637: startOffset + insertString.normalize('NFC').length, 638: ) 639: } 640: insert(insertString: string): Cursor { 641: const newCursor = this.modifyText(this, insertString) 642: return newCursor 643: } 644: del(): Cursor { 645: if (this.isAtEnd()) { 646: return this 647: } 648: return this.modifyText(this.right()) 649: } 650: backspace(): Cursor { 651: if (this.isAtStart()) { 652: return this 653: } 654: return this.left().modifyText(this) 655: } 656: deleteToLineStart(): { cursor: Cursor; killed: string } { 657: if (this.offset > 0 && this.text[this.offset - 1] === '\n') { 658: return { cursor: this.left().modifyText(this), killed: '\n' } 659: } 660: const startCursor = this.startOfLine() 661: const killed = this.text.slice(startCursor.offset, this.offset) 662: return { cursor: startCursor.modifyText(this), killed } 663: } 664: deleteToLineEnd(): { cursor: Cursor; killed: string } { 665: if (this.text[this.offset] === '\n') { 666: return { cursor: this.modifyText(this.right()), killed: '\n' } 667: } 668: const endCursor = this.endOfLine() 669: const killed = this.text.slice(this.offset, endCursor.offset) 670: return { cursor: this.modifyText(endCursor), killed } 671: } 672: deleteToLogicalLineEnd(): Cursor { 673: if (this.text[this.offset] === '\n') { 674: return this.modifyText(this.right()) 675: } 676: return this.modifyText(this.endOfLogicalLine()) 677: } 678: deleteWordBefore(): { cursor: Cursor; killed: string } { 679: if (this.isAtStart()) { 680: return { cursor: this, killed: '' } 681: } 682: const target = this.snapOutOfImageRef(this.prevWord().offset, 'start') 683: const prevWordCursor = new Cursor(this.measuredText, target) 684: const killed = this.text.slice(prevWordCursor.offset, this.offset) 685: return { cursor: prevWordCursor.modifyText(this), killed } 686: } 687: deleteTokenBefore(): Cursor | null { 688: const chipAfter = this.imageRefStartingAt(this.offset) 689: if (chipAfter) { 690: const end = 691: this.text[chipAfter.end] === ' ' ? chipAfter.end + 1 : chipAfter.end 692: return this.modifyText(new Cursor(this.measuredText, end)) 693: } 694: if (this.isAtStart()) { 695: return null 696: } 697: const charAfter = this.text[this.offset] 698: if (charAfter !== undefined && !/\s/.test(charAfter)) { 699: return null 700: } 701: const textBefore = this.text.slice(0, this.offset) 702: const pasteMatch = textBefore.match( 703: /(^|\s)\[(Pasted text #\d+(?: \+\d+ lines)?|Image #\d+|\.\.\.Truncated text #\d+ \+\d+ lines\.\.\.)\]$/, 704: ) 705: if (pasteMatch) { 706: const matchStart = pasteMatch.index! + pasteMatch[1]!.length 707: return new Cursor(this.measuredText, matchStart).modifyText(this) 708: } 709: return null 710: } 711: deleteWordAfter(): Cursor { 712: if (this.isAtEnd()) { 713: return this 714: } 715: const target = this.snapOutOfImageRef(this.nextWord().offset, 'end') 716: return this.modifyText(new Cursor(this.measuredText, target)) 717: } 718: private graphemeAt(pos: number): string { 719: if (pos >= this.text.length) return '' 720: const nextOff = this.measuredText.nextOffset(pos) 721: return this.text.slice(pos, nextOff) 722: } 723: private isOverWhitespace(): boolean { 724: const currentChar = this.text[this.offset] ?? '' 725: return /\s/.test(currentChar) 726: } 727: equals(other: Cursor): boolean { 728: return ( 729: this.offset === other.offset && this.measuredText === other.measuredText 730: ) 731: } 732: isAtStart(): boolean { 733: return this.offset === 0 734: } 735: isAtEnd(): boolean { 736: return this.offset >= this.text.length 737: } 738: startOfFirstLine(): Cursor { 739: // Go to the very beginning of the text (first character of first line) 740: return new Cursor(this.measuredText, 0, 0) 741: } 742: startOfLastLine(): Cursor { 743: // Go to the beginning of the last line 744: const lastNewlineIndex = this.text.lastIndexOf('\n') 745: if (lastNewlineIndex === -1) { 746: return this.startOfLine() 747: } 748: return new Cursor(this.measuredText, lastNewlineIndex + 1, 0) 749: } 750: goToLine(lineNumber: number): Cursor { 751: const lines = this.text.split('\n') 752: const targetLine = Math.min(Math.max(0, lineNumber - 1), lines.length - 1) 753: let offset = 0 754: for (let i = 0; i < targetLine; i++) { 755: offset += (lines[i]?.length ?? 0) + 1 756: } 757: return new Cursor(this.measuredText, offset, 0) 758: } 759: endOfFile(): Cursor { 760: return new Cursor(this.measuredText, this.text.length, 0) 761: } 762: public get text(): string { 763: return this.measuredText.text 764: } 765: private get columns(): number { 766: return this.measuredText.columns + 1 767: } 768: getPosition(): Position { 769: return this.measuredText.getPositionFromOffset(this.offset) 770: } 771: private getOffset(position: Position): number { 772: return this.measuredText.getOffsetFromPosition(position) 773: } 774: findCharacter( 775: char: string, 776: type: 'f' | 'F' | 't' | 'T', 777: count: number = 1, 778: ): number | null { 779: const text = this.text 780: const forward = type === 'f' || type === 't' 781: const till = type === 't' || type === 'T' 782: let found = 0 783: if (forward) { 784: let pos = this.measuredText.nextOffset(this.offset) 785: while (pos < text.length) { 786: const grapheme = this.graphemeAt(pos) 787: if (grapheme === char) { 788: found++ 789: if (found === count) { 790: return till 791: ? Math.max(this.offset, this.measuredText.prevOffset(pos)) 792: : pos 793: } 794: } 795: pos = this.measuredText.nextOffset(pos) 796: } 797: } else { 798: if (this.offset === 0) return null 799: let pos = this.measuredText.prevOffset(this.offset) 800: while (pos >= 0) { 801: const grapheme = this.graphemeAt(pos) 802: if (grapheme === char) { 803: found++ 804: if (found === count) { 805: return till 806: ? Math.min(this.offset, this.measuredText.nextOffset(pos)) 807: : pos 808: } 809: } 810: if (pos === 0) break 811: pos = this.measuredText.prevOffset(pos) 812: } 813: } 814: return null 815: } 816: } 817: class WrappedLine { 818: constructor( 819: public readonly text: string, 820: public readonly startOffset: number, 821: public readonly isPrecededByNewline: boolean, 822: public readonly endsWithNewline: boolean = false, 823: ) {} 824: equals(other: WrappedLine): boolean { 825: return this.text === other.text && this.startOffset === other.startOffset 826: } 827: get length(): number { 828: return this.text.length + (this.endsWithNewline ? 1 : 0) 829: } 830: } 831: export class MeasuredText { 832: private _wrappedLines?: WrappedLine[] 833: public readonly text: string 834: private navigationCache: Map<string, number> 835: private graphemeBoundaries?: number[] 836: constructor( 837: text: string, 838: readonly columns: number, 839: ) { 840: this.text = text.normalize('NFC') 841: this.navigationCache = new Map() 842: } 843: private get wrappedLines(): WrappedLine[] { 844: if (!this._wrappedLines) { 845: this._wrappedLines = this.measureWrappedText() 846: } 847: return this._wrappedLines 848: } 849: private getGraphemeBoundaries(): number[] { 850: if (!this.graphemeBoundaries) { 851: this.graphemeBoundaries = [] 852: for (const { index } of getGraphemeSegmenter().segment(this.text)) { 853: this.graphemeBoundaries.push(index) 854: } 855: this.graphemeBoundaries.push(this.text.length) 856: } 857: return this.graphemeBoundaries 858: } 859: private wordBoundariesCache?: Array<{ 860: start: number 861: end: number 862: isWordLike: boolean 863: }> 864: public getWordBoundaries(): Array<{ 865: start: number 866: end: number 867: isWordLike: boolean 868: }> { 869: if (!this.wordBoundariesCache) { 870: this.wordBoundariesCache = [] 871: for (const segment of getWordSegmenter().segment(this.text)) { 872: this.wordBoundariesCache.push({ 873: start: segment.index, 874: end: segment.index + segment.segment.length, 875: isWordLike: segment.isWordLike ?? false, 876: }) 877: } 878: } 879: return this.wordBoundariesCache 880: } 881: private binarySearchBoundary( 882: boundaries: number[], 883: target: number, 884: findNext: boolean, 885: ): number { 886: let left = 0 887: let right = boundaries.length - 1 888: let result = findNext ? this.text.length : 0 889: while (left <= right) { 890: const mid = Math.floor((left + right) / 2) 891: const boundary = boundaries[mid] 892: if (boundary === undefined) break 893: if (findNext) { 894: if (boundary > target) { 895: result = boundary 896: right = mid - 1 897: } else { 898: left = mid + 1 899: } 900: } else { 901: if (boundary < target) { 902: result = boundary 903: left = mid + 1 904: } else { 905: right = mid - 1 906: } 907: } 908: } 909: return result 910: } 911: public stringIndexToDisplayWidth(text: string, index: number): number { 912: if (index <= 0) return 0 913: if (index >= text.length) return stringWidth(text) 914: return stringWidth(text.substring(0, index)) 915: } 916: public displayWidthToStringIndex(text: string, targetWidth: number): number { 917: if (targetWidth <= 0) return 0 918: if (!text) return 0 919: if (text === this.text) { 920: return this.offsetAtDisplayWidth(targetWidth) 921: } 922: let currentWidth = 0 923: let currentOffset = 0 924: for (const { segment, index } of getGraphemeSegmenter().segment(text)) { 925: const segmentWidth = stringWidth(segment) 926: if (currentWidth + segmentWidth > targetWidth) { 927: break 928: } 929: currentWidth += segmentWidth 930: currentOffset = index + segment.length 931: } 932: return currentOffset 933: } 934: private offsetAtDisplayWidth(targetWidth: number): number { 935: if (targetWidth <= 0) return 0 936: let currentWidth = 0 937: const boundaries = this.getGraphemeBoundaries() 938: for (let i = 0; i < boundaries.length - 1; i++) { 939: const start = boundaries[i] 940: const end = boundaries[i + 1] 941: if (start === undefined || end === undefined) continue 942: const segment = this.text.substring(start, end) 943: const segmentWidth = stringWidth(segment) 944: if (currentWidth + segmentWidth > targetWidth) { 945: return start 946: } 947: currentWidth += segmentWidth 948: } 949: return this.text.length 950: } 951: private measureWrappedText(): WrappedLine[] { 952: const wrappedText = wrapAnsi(this.text, this.columns, { 953: hard: true, 954: trim: false, 955: }) 956: const wrappedLines: WrappedLine[] = [] 957: let searchOffset = 0 958: let lastNewLinePos = -1 959: const lines = wrappedText.split('\n') 960: for (let i = 0; i < lines.length; i++) { 961: const text = lines[i]! 962: const isPrecededByNewline = (startOffset: number) => 963: i === 0 || (startOffset > 0 && this.text[startOffset - 1] === '\n') 964: if (text.length === 0) { 965: lastNewLinePos = this.text.indexOf('\n', lastNewLinePos + 1) 966: if (lastNewLinePos !== -1) { 967: const startOffset = lastNewLinePos 968: const endsWithNewline = true 969: wrappedLines.push( 970: new WrappedLine( 971: text, 972: startOffset, 973: isPrecededByNewline(startOffset), 974: endsWithNewline, 975: ), 976: ) 977: } else { 978: const startOffset = this.text.length 979: wrappedLines.push( 980: new WrappedLine( 981: text, 982: startOffset, 983: isPrecededByNewline(startOffset), 984: false, 985: ), 986: ) 987: } 988: } else { 989: const startOffset = this.text.indexOf(text, searchOffset) 990: if (startOffset === -1) { 991: throw new Error('Failed to find wrapped line in text') 992: } 993: searchOffset = startOffset + text.length 994: const potentialNewlinePos = startOffset + text.length 995: const endsWithNewline = 996: potentialNewlinePos < this.text.length && 997: this.text[potentialNewlinePos] === '\n' 998: if (endsWithNewline) { 999: lastNewLinePos = potentialNewlinePos 1000: } 1001: wrappedLines.push( 1002: new WrappedLine( 1003: text, 1004: startOffset, 1005: isPrecededByNewline(startOffset), 1006: endsWithNewline, 1007: ), 1008: ) 1009: } 1010: } 1011: return wrappedLines 1012: } 1013: public getWrappedText(): WrappedText { 1014: return this.wrappedLines.map(line => 1015: line.isPrecededByNewline ? line.text : line.text.trimStart(), 1016: ) 1017: } 1018: public getWrappedLines(): WrappedLine[] { 1019: return this.wrappedLines 1020: } 1021: private getLine(line: number): WrappedLine { 1022: const lines = this.wrappedLines 1023: return lines[Math.max(0, Math.min(line, lines.length - 1))]! 1024: } 1025: public getOffsetFromPosition(position: Position): number { 1026: const wrappedLine = this.getLine(position.line) 1027: if (wrappedLine.text.length === 0 && wrappedLine.endsWithNewline) { 1028: return wrappedLine.startOffset 1029: } 1030: const leadingWhitespace = wrappedLine.isPrecededByNewline 1031: ? 0 1032: : wrappedLine.text.length - wrappedLine.text.trimStart().length 1033: const displayColumnWithLeading = position.column + leadingWhitespace 1034: const stringIndex = this.displayWidthToStringIndex( 1035: wrappedLine.text, 1036: displayColumnWithLeading, 1037: ) 1038: const offset = wrappedLine.startOffset + stringIndex 1039: const lineEnd = wrappedLine.startOffset + wrappedLine.text.length 1040: let maxOffset = lineEnd 1041: const lineDisplayWidth = stringWidth(wrappedLine.text) 1042: if (wrappedLine.endsWithNewline && position.column > lineDisplayWidth) { 1043: maxOffset = lineEnd + 1 1044: } 1045: return Math.min(offset, maxOffset) 1046: } 1047: public getLineLength(line: number): number { 1048: const wrappedLine = this.getLine(line) 1049: return stringWidth(wrappedLine.text) 1050: } 1051: public getPositionFromOffset(offset: number): Position { 1052: const lines = this.wrappedLines 1053: for (let line = 0; line < lines.length; line++) { 1054: const currentLine = lines[line]! 1055: const nextLine = lines[line + 1] 1056: if ( 1057: offset >= currentLine.startOffset && 1058: (!nextLine || offset < nextLine.startOffset) 1059: ) { 1060: const stringPosInLine = offset - currentLine.startOffset 1061: let displayColumn: number 1062: if (currentLine.isPrecededByNewline) { 1063: displayColumn = this.stringIndexToDisplayWidth( 1064: currentLine.text, 1065: stringPosInLine, 1066: ) 1067: } else { 1068: const leadingWhitespace = 1069: currentLine.text.length - currentLine.text.trimStart().length 1070: if (stringPosInLine < leadingWhitespace) { 1071: displayColumn = 0 1072: } else { 1073: const trimmedText = currentLine.text.trimStart() 1074: const posInTrimmed = stringPosInLine - leadingWhitespace 1075: displayColumn = this.stringIndexToDisplayWidth( 1076: trimmedText, 1077: posInTrimmed, 1078: ) 1079: } 1080: } 1081: return { 1082: line, 1083: column: Math.max(0, displayColumn), 1084: } 1085: } 1086: } 1087: const line = lines.length - 1 1088: const lastLine = this.wrappedLines[line]! 1089: return { 1090: line, 1091: column: stringWidth(lastLine.text), 1092: } 1093: } 1094: public get lineCount(): number { 1095: return this.wrappedLines.length 1096: } 1097: private withCache<T>(key: string, compute: () => T): T { 1098: const cached = this.navigationCache.get(key) 1099: if (cached !== undefined) return cached as T 1100: const result = compute() 1101: this.navigationCache.set(key, result as number) 1102: return result 1103: } 1104: nextOffset(offset: number): number { 1105: return this.withCache(`next:${offset}`, () => { 1106: const boundaries = this.getGraphemeBoundaries() 1107: return this.binarySearchBoundary(boundaries, offset, true) 1108: }) 1109: } 1110: prevOffset(offset: number): number { 1111: if (offset <= 0) return 0 1112: return this.withCache(`prev:${offset}`, () => { 1113: const boundaries = this.getGraphemeBoundaries() 1114: return this.binarySearchBoundary(boundaries, offset, false) 1115: }) 1116: } 1117: snapToGraphemeBoundary(offset: number): number { 1118: if (offset <= 0) return 0 1119: if (offset >= this.text.length) return this.text.length 1120: const boundaries = this.getGraphemeBoundaries() 1121: let lo = 0 1122: let hi = boundaries.length - 1 1123: while (lo < hi) { 1124: const mid = (lo + hi + 1) >> 1 1125: if (boundaries[mid]! <= offset) lo = mid 1126: else hi = mid - 1 1127: } 1128: return boundaries[lo]! 1129: } 1130: }

File: src/utils/cwd.ts

typescript 1: import { AsyncLocalStorage } from 'async_hooks' 2: import { getCwdState, getOriginalCwd } from '../bootstrap/state.js' 3: const cwdOverrideStorage = new AsyncLocalStorage<string>() 4: export function runWithCwdOverride<T>(cwd: string, fn: () => T): T { 5: return cwdOverrideStorage.run(cwd, fn) 6: } 7: export function pwd(): string { 8: return cwdOverrideStorage.getStore() ?? getCwdState() 9: } 10: export function getCwd(): string { 11: try { 12: return pwd() 13: } catch { 14: return getOriginalCwd() 15: } 16: }

File: src/utils/debug.ts

typescript 1: import { appendFile, mkdir, symlink, unlink } from 'fs/promises' 2: import memoize from 'lodash-es/memoize.js' 3: import { dirname, join } from 'path' 4: import { getSessionId } from 'src/bootstrap/state.js' 5: import { type BufferedWriter, createBufferedWriter } from './bufferedWriter.js' 6: import { registerCleanup } from './cleanupRegistry.js' 7: import { 8: type DebugFilter, 9: parseDebugFilter, 10: shouldShowDebugMessage, 11: } from './debugFilter.js' 12: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 13: import { getFsImplementation } from './fsOperations.js' 14: import { writeToStderr } from './process.js' 15: import { jsonStringify } from './slowOperations.js' 16: export type DebugLogLevel = 'verbose' | 'debug' | 'info' | 'warn' | 'error' 17: const LEVEL_ORDER: Record<DebugLogLevel, number> = { 18: verbose: 0, 19: debug: 1, 20: info: 2, 21: warn: 3, 22: error: 4, 23: } 24: export const getMinDebugLogLevel = memoize((): DebugLogLevel => { 25: const raw = process.env.CLAUDE_CODE_DEBUG_LOG_LEVEL?.toLowerCase().trim() 26: if (raw && Object.hasOwn(LEVEL_ORDER, raw)) { 27: return raw as DebugLogLevel 28: } 29: return 'debug' 30: }) 31: let runtimeDebugEnabled = false 32: export const isDebugMode = memoize((): boolean => { 33: return ( 34: runtimeDebugEnabled || 35: isEnvTruthy(process.env.DEBUG) || 36: isEnvTruthy(process.env.DEBUG_SDK) || 37: process.argv.includes('--debug') || 38: process.argv.includes('-d') || 39: isDebugToStdErr() || 40: process.argv.some(arg => arg.startsWith('--debug=')) || 41: getDebugFilePath() !== null 42: ) 43: }) 44: export function enableDebugLogging(): boolean { 45: const wasActive = isDebugMode() || process.env.USER_TYPE === 'ant' 46: runtimeDebugEnabled = true 47: isDebugMode.cache.clear?.() 48: return wasActive 49: } 50: export const getDebugFilter = memoize((): DebugFilter | null => { 51: const debugArg = process.argv.find(arg => arg.startsWith('--debug=')) 52: if (!debugArg) { 53: return null 54: } 55: const filterPattern = debugArg.substring('--debug='.length) 56: return parseDebugFilter(filterPattern) 57: }) 58: export const isDebugToStdErr = memoize((): boolean => { 59: return ( 60: process.argv.includes('--debug-to-stderr') || process.argv.includes('-d2e') 61: ) 62: }) 63: export const getDebugFilePath = memoize((): string | null => { 64: for (let i = 0; i < process.argv.length; i++) { 65: const arg = process.argv[i]! 66: if (arg.startsWith('--debug-file=')) { 67: return arg.substring('--debug-file='.length) 68: } 69: if (arg === '--debug-file' && i + 1 < process.argv.length) { 70: return process.argv[i + 1]! 71: } 72: } 73: return null 74: }) 75: function shouldLogDebugMessage(message: string): boolean { 76: if (process.env.NODE_ENV === 'test' && !isDebugToStdErr()) { 77: return false 78: } 79: if (process.env.USER_TYPE !== 'ant' && !isDebugMode()) { 80: return false 81: } 82: if ( 83: typeof process === 'undefined' || 84: typeof process.versions === 'undefined' || 85: typeof process.versions.node === 'undefined' 86: ) { 87: return false 88: } 89: const filter = getDebugFilter() 90: return shouldShowDebugMessage(message, filter) 91: } 92: let hasFormattedOutput = false 93: export function setHasFormattedOutput(value: boolean): void { 94: hasFormattedOutput = value 95: } 96: export function getHasFormattedOutput(): boolean { 97: return hasFormattedOutput 98: } 99: let debugWriter: BufferedWriter | null = null 100: let pendingWrite: Promise<void> = Promise.resolve() 101: async function appendAsync( 102: needMkdir: boolean, 103: dir: string, 104: path: string, 105: content: string, 106: ): Promise<void> { 107: if (needMkdir) { 108: await mkdir(dir, { recursive: true }).catch(() => {}) 109: } 110: await appendFile(path, content) 111: void updateLatestDebugLogSymlink() 112: } 113: function noop(): void {} 114: function getDebugWriter(): BufferedWriter { 115: if (!debugWriter) { 116: let ensuredDir: string | null = null 117: debugWriter = createBufferedWriter({ 118: writeFn: content => { 119: const path = getDebugLogPath() 120: const dir = dirname(path) 121: const needMkdir = ensuredDir !== dir 122: ensuredDir = dir 123: if (isDebugMode()) { 124: if (needMkdir) { 125: try { 126: getFsImplementation().mkdirSync(dir) 127: } catch { 128: } 129: } 130: getFsImplementation().appendFileSync(path, content) 131: void updateLatestDebugLogSymlink() 132: return 133: } 134: pendingWrite = pendingWrite 135: .then(appendAsync.bind(null, needMkdir, dir, path, content)) 136: .catch(noop) 137: }, 138: flushIntervalMs: 1000, 139: maxBufferSize: 100, 140: immediateMode: isDebugMode(), 141: }) 142: registerCleanup(async () => { 143: debugWriter?.dispose() 144: await pendingWrite 145: }) 146: } 147: return debugWriter 148: } 149: export async function flushDebugLogs(): Promise<void> { 150: debugWriter?.flush() 151: await pendingWrite 152: } 153: export function logForDebugging( 154: message: string, 155: { level }: { level: DebugLogLevel } = { 156: level: 'debug', 157: }, 158: ): void { 159: if (LEVEL_ORDER[level] < LEVEL_ORDER[getMinDebugLogLevel()]) { 160: return 161: } 162: if (!shouldLogDebugMessage(message)) { 163: return 164: } 165: if (hasFormattedOutput && message.includes('\n')) { 166: message = jsonStringify(message) 167: } 168: const timestamp = new Date().toISOString() 169: const output = `${timestamp} [${level.toUpperCase()}] ${message.trim()}\n` 170: if (isDebugToStdErr()) { 171: writeToStderr(output) 172: return 173: } 174: getDebugWriter().write(output) 175: } 176: export function getDebugLogPath(): string { 177: return ( 178: getDebugFilePath() ?? 179: process.env.CLAUDE_CODE_DEBUG_LOGS_DIR ?? 180: join(getClaudeConfigHomeDir(), 'debug', `${getSessionId()}.txt`) 181: ) 182: } 183: const updateLatestDebugLogSymlink = memoize(async (): Promise<void> => { 184: try { 185: const debugLogPath = getDebugLogPath() 186: const debugLogsDir = dirname(debugLogPath) 187: const latestSymlinkPath = join(debugLogsDir, 'latest') 188: await unlink(latestSymlinkPath).catch(() => {}) 189: await symlink(debugLogPath, latestSymlinkPath) 190: } catch { 191: } 192: }) 193: export function logAntError(context: string, error: unknown): void { 194: if (process.env.USER_TYPE !== 'ant') { 195: return 196: } 197: if (error instanceof Error && error.stack) { 198: logForDebugging(`[ANT-ONLY] ${context} stack trace:\n${error.stack}`, { 199: level: 'error', 200: }) 201: } 202: }

File: src/utils/debugFilter.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: export type DebugFilter = { 3: include: string[] 4: exclude: string[] 5: isExclusive: boolean 6: } 7: export const parseDebugFilter = memoize( 8: (filterString?: string): DebugFilter | null => { 9: if (!filterString || filterString.trim() === '') { 10: return null 11: } 12: const filters = filterString 13: .split(',') 14: .map(f => f.trim()) 15: .filter(Boolean) 16: // If no valid filters remain, return null 17: if (filters.length === 0) { 18: return null 19: } 20: // Check for mixed inclusive/exclusive filters 21: const hasExclusive = filters.some(f => f.startsWith('!')) 22: const hasInclusive = filters.some(f => !f.startsWith('!')) 23: if (hasExclusive && hasInclusive) { 24: // For now, we'll treat this as an error case and show all messages 25: return null 26: } 27: const cleanFilters = filters.map(f => f.replace(/^!/, '').toLowerCase()) 28: return { 29: include: hasExclusive ? [] : cleanFilters, 30: exclude: hasExclusive ? cleanFilters : [], 31: isExclusive: hasExclusive, 32: } 33: }, 34: ) 35: /** 36: * Extract debug categories from a message 37: * Supports multiple patterns: 38: * - "category: message" -> ["category"] 39: * - "[CATEGORY] message" -> ["category"] 40: * - "MCP server \"name\": message" -> ["mcp", "name"] 41: * - "[ANT-ONLY] 1P event: tengu_timer" -> ["ant-only", "1p"] 42: * 43: * Returns lowercase categories for case-insensitive matching 44: */ 45: export function extractDebugCategories(message: string): string[] { 46: const categories: string[] = [] 47: // Pattern 3: MCP server "servername" - Check this first to avoid false positives 48: const mcpMatch = message.match(/^MCP server ["']([^"']+)["']/) 49: if (mcpMatch && mcpMatch[1]) { 50: categories.push('mcp') 51: categories.push(mcpMatch[1].toLowerCase()) 52: } else { 53: const prefixMatch = message.match(/^([^:[]+):/) 54: if (prefixMatch && prefixMatch[1]) { 55: categories.push(prefixMatch[1].trim().toLowerCase()) 56: } 57: } 58: const bracketMatch = message.match(/^\[([^\]]+)]/) 59: if (bracketMatch && bracketMatch[1]) { 60: categories.push(bracketMatch[1].trim().toLowerCase()) 61: } 62: if (message.toLowerCase().includes('1p event:')) { 63: categories.push('1p') 64: } 65: const secondaryMatch = message.match( 66: /:\s*([^:]+?)(?:\s+(?:type|mode|status|event))?:/, 67: ) 68: if (secondaryMatch && secondaryMatch[1]) { 69: const secondary = secondaryMatch[1].trim().toLowerCase() 70: if (secondary.length < 30 && !secondary.includes(' ')) { 71: categories.push(secondary) 72: } 73: } 74: return Array.from(new Set(categories)) 75: } 76: export function shouldShowDebugCategories( 77: categories: string[], 78: filter: DebugFilter | null, 79: ): boolean { 80: if (!filter) { 81: return true 82: } 83: if (categories.length === 0) { 84: return false 85: } 86: if (filter.isExclusive) { 87: return !categories.some(cat => filter.exclude.includes(cat)) 88: } else { 89: return categories.some(cat => filter.include.includes(cat)) 90: } 91: } 92: export function shouldShowDebugMessage( 93: message: string, 94: filter: DebugFilter | null, 95: ): boolean { 96: if (!filter) { 97: return true 98: } 99: const categories = extractDebugCategories(message) 100: return shouldShowDebugCategories(categories, filter) 101: }

File: src/utils/desktopDeepLink.ts

typescript 1: import { readdir } from 'fs/promises' 2: import { join } from 'path' 3: import { coerce as semverCoerce } from 'semver' 4: import { getSessionId } from '../bootstrap/state.js' 5: import { getCwd } from './cwd.js' 6: import { logForDebugging } from './debug.js' 7: import { execFileNoThrow } from './execFileNoThrow.js' 8: import { pathExists } from './file.js' 9: import { gte as semverGte } from './semver.js' 10: const MIN_DESKTOP_VERSION = '1.1.2396' 11: function isDevMode(): boolean { 12: if ((process.env.NODE_ENV as string) === 'development') { 13: return true 14: } 15: const pathsToCheck = [process.argv[1] || '', process.execPath || ''] 16: const buildDirs = [ 17: '/build-ant/', 18: '/build-ant-native/', 19: '/build-external/', 20: '/build-external-native/', 21: ] 22: return pathsToCheck.some(p => buildDirs.some(dir => p.includes(dir))) 23: } 24: /** 25: * Builds a deep link URL for Claude Desktop to resume a CLI session. 26: * Format: claude://resume?session={sessionId}&cwd={cwd} 27: * In dev mode: claude-dev://resume?session={sessionId}&cwd={cwd} 28: */ 29: function buildDesktopDeepLink(sessionId: string): string { 30: const protocol = isDevMode() ? 'claude-dev' : 'claude' 31: const url = new URL(`${protocol}://resume`) 32: url.searchParams.set('session', sessionId) 33: url.searchParams.set('cwd', getCwd()) 34: return url.toString() 35: } 36: async function isDesktopInstalled(): Promise<boolean> { 37: if (isDevMode()) { 38: return true 39: } 40: const platform = process.platform 41: if (platform === 'darwin') { 42: return pathExists('/Applications/Claude.app') 43: } else if (platform === 'linux') { 44: const { code, stdout } = await execFileNoThrow('xdg-mime', [ 45: 'query', 46: 'default', 47: 'x-scheme-handler/claude', 48: ]) 49: return code === 0 && stdout.trim().length > 0 50: } else if (platform === 'win32') { 51: const { code } = await execFileNoThrow('reg', [ 52: 'query', 53: 'HKEY_CLASSES_ROOT\\claude', 54: '/ve', 55: ]) 56: return code === 0 57: } 58: return false 59: } 60: async function getDesktopVersion(): Promise<string | null> { 61: const platform = process.platform 62: if (platform === 'darwin') { 63: const { code, stdout } = await execFileNoThrow('defaults', [ 64: 'read', 65: '/Applications/Claude.app/Contents/Info.plist', 66: 'CFBundleShortVersionString', 67: ]) 68: if (code !== 0) { 69: return null 70: } 71: const version = stdout.trim() 72: return version.length > 0 ? version : null 73: } else if (platform === 'win32') { 74: const localAppData = process.env.LOCALAPPDATA 75: if (!localAppData) { 76: return null 77: } 78: const installDir = join(localAppData, 'AnthropicClaude') 79: try { 80: const entries = await readdir(installDir) 81: const versions = entries 82: .filter(e => e.startsWith('app-')) 83: .map(e => e.slice(4)) 84: .filter(v => semverCoerce(v) !== null) 85: .sort((a, b) => { 86: const ca = semverCoerce(a)! 87: const cb = semverCoerce(b)! 88: return ca.compare(cb) 89: }) 90: return versions.length > 0 ? versions[versions.length - 1]! : null 91: } catch { 92: return null 93: } 94: } 95: return null 96: } 97: export type DesktopInstallStatus = 98: | { status: 'not-installed' } 99: | { status: 'version-too-old'; version: string } 100: | { status: 'ready'; version: string } 101: export async function getDesktopInstallStatus(): Promise<DesktopInstallStatus> { 102: const installed = await isDesktopInstalled() 103: if (!installed) { 104: return { status: 'not-installed' } 105: } 106: let version: string | null 107: try { 108: version = await getDesktopVersion() 109: } catch { 110: return { status: 'ready', version: 'unknown' } 111: } 112: if (!version) { 113: return { status: 'ready', version: 'unknown' } 114: } 115: const coerced = semverCoerce(version) 116: if (!coerced || !semverGte(coerced.version, MIN_DESKTOP_VERSION)) { 117: return { status: 'version-too-old', version } 118: } 119: return { status: 'ready', version } 120: } 121: async function openDeepLink(deepLinkUrl: string): Promise<boolean> { 122: const platform = process.platform 123: logForDebugging(`Opening deep link: ${deepLinkUrl}`) 124: if (platform === 'darwin') { 125: if (isDevMode()) { 126: const { code } = await execFileNoThrow('osascript', [ 127: '-e', 128: `tell application "Electron" to open location "${deepLinkUrl}"`, 129: ]) 130: return code === 0 131: } 132: const { code } = await execFileNoThrow('open', [deepLinkUrl]) 133: return code === 0 134: } else if (platform === 'linux') { 135: const { code } = await execFileNoThrow('xdg-open', [deepLinkUrl]) 136: return code === 0 137: } else if (platform === 'win32') { 138: const { code } = await execFileNoThrow('cmd', [ 139: '/c', 140: 'start', 141: '', 142: deepLinkUrl, 143: ]) 144: return code === 0 145: } 146: return false 147: } 148: /** 149: * Build and open a deep link to resume the current session in Claude Desktop. 150: * Returns an object with success status and any error message. 151: */ 152: export async function openCurrentSessionInDesktop(): Promise<{ 153: success: boolean 154: error?: string 155: deepLinkUrl?: string 156: }> { 157: const sessionId = getSessionId() 158: // Check if Desktop is installed 159: const installed = await isDesktopInstalled() 160: if (!installed) { 161: return { 162: success: false, 163: error: 164: 'Claude Desktop is not installed. Install it from https: 165: } 166: } 167: const deepLinkUrl = buildDesktopDeepLink(sessionId) 168: const opened = await openDeepLink(deepLinkUrl) 169: if (!opened) { 170: return { 171: success: false, 172: error: 'Failed to open Claude Desktop. Please try opening it manually.', 173: deepLinkUrl, 174: } 175: } 176: return { success: true, deepLinkUrl } 177: }

File: src/utils/detectRepository.ts

typescript 1: import { getCwd } from './cwd.js' 2: import { logForDebugging } from './debug.js' 3: import { getRemoteUrl } from './git.js' 4: export type ParsedRepository = { 5: host: string 6: owner: string 7: name: string 8: } 9: const repositoryWithHostCache = new Map<string, ParsedRepository | null>() 10: export function clearRepositoryCaches(): void { 11: repositoryWithHostCache.clear() 12: } 13: export async function detectCurrentRepository(): Promise<string | null> { 14: const result = await detectCurrentRepositoryWithHost() 15: if (!result) return null 16: if (result.host !== 'github.com') return null 17: return `${result.owner}/${result.name}` 18: } 19: export async function detectCurrentRepositoryWithHost(): Promise<ParsedRepository | null> { 20: const cwd = getCwd() 21: if (repositoryWithHostCache.has(cwd)) { 22: return repositoryWithHostCache.get(cwd) ?? null 23: } 24: try { 25: const remoteUrl = await getRemoteUrl() 26: logForDebugging(`Git remote URL: ${remoteUrl}`) 27: if (!remoteUrl) { 28: logForDebugging('No git remote URL found') 29: repositoryWithHostCache.set(cwd, null) 30: return null 31: } 32: const parsed = parseGitRemote(remoteUrl) 33: logForDebugging( 34: `Parsed repository: ${parsed ? `${parsed.host}/${parsed.owner}/${parsed.name}` : null} from URL: ${remoteUrl}`, 35: ) 36: repositoryWithHostCache.set(cwd, parsed) 37: return parsed 38: } catch (error) { 39: logForDebugging(`Error detecting repository: ${error}`) 40: repositoryWithHostCache.set(cwd, null) 41: return null 42: } 43: } 44: export function getCachedRepository(): string | null { 45: const parsed = repositoryWithHostCache.get(getCwd()) 46: if (!parsed || parsed.host !== 'github.com') return null 47: return `${parsed.owner}/${parsed.name}` 48: } 49: export function parseGitRemote(input: string): ParsedRepository | null { 50: const trimmed = input.trim() 51: const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/) 52: if (sshMatch?.[1] && sshMatch[2] && sshMatch[3]) { 53: if (!looksLikeRealHostname(sshMatch[1])) return null 54: return { 55: host: sshMatch[1], 56: owner: sshMatch[2], 57: name: sshMatch[3], 58: } 59: } 60: const urlMatch = trimmed.match( 61: /^(https?|ssh|git):\/\/(?:[^@]+@)?([^/:]+(?::\d+)?)\/([^/]+)\/([^/]+?)(?:\.git)?$/, 62: ) 63: if (urlMatch?.[1] && urlMatch[2] && urlMatch[3] && urlMatch[4]) { 64: const protocol = urlMatch[1] 65: const hostWithPort = urlMatch[2] 66: const hostWithoutPort = hostWithPort.split(':')[0] ?? '' 67: if (!looksLikeRealHostname(hostWithoutPort)) return null 68: // Only preserve port for HTTPS — SSH/git ports are not usable for constructing 69: // web URLs (e.g. ssh://git@ghe.corp.com:2222 → port 2222 is SSH, not HTTPS). 70: const host = 71: protocol === 'https' || protocol === 'http' 72: ? hostWithPort 73: : hostWithoutPort 74: return { 75: host, 76: owner: urlMatch[3], 77: name: urlMatch[4], 78: } 79: } 80: return null 81: } 82: export function parseGitHubRepository(input: string): string | null { 83: const trimmed = input.trim() 84: const parsed = parseGitRemote(trimmed) 85: if (parsed) { 86: if (parsed.host !== 'github.com') return null 87: return `${parsed.owner}/${parsed.name}` 88: } 89: if ( 90: !trimmed.includes('://') && 91: !trimmed.includes('@') && 92: trimmed.includes('/') 93: ) { 94: const parts = trimmed.split('/') 95: if (parts.length === 2 && parts[0] && parts[1]) { 96: const repo = parts[1].replace(/\.git$/, '') 97: return `${parts[0]}/${repo}` 98: } 99: } 100: logForDebugging(`Could not parse repository from: ${trimmed}`) 101: return null 102: } 103: /** 104: * Checks whether a hostname looks like a real domain name rather than an 105: * SSH config alias. A simple dot-check is not enough because aliases like 106: * "github.com-work" still contain a dot. We additionally require that the 107: * last segment (the TLD) is purely alphabetic — real TLDs (com, org, io, net) 108: * never contain hyphens or digits. 109: */ 110: function looksLikeRealHostname(host: string): boolean { 111: if (!host.includes('.')) return false 112: const lastSegment = host.split('.').pop() 113: if (!lastSegment) return false 114: return /^[a-zA-Z]+$/.test(lastSegment) 115: }

File: src/utils/diagLogs.ts

typescript 1: import { dirname } from 'path' 2: import { getFsImplementation } from './fsOperations.js' 3: import { jsonStringify } from './slowOperations.js' 4: type DiagnosticLogLevel = 'debug' | 'info' | 'warn' | 'error' 5: type DiagnosticLogEntry = { 6: timestamp: string 7: level: DiagnosticLogLevel 8: event: string 9: data: Record<string, unknown> 10: } 11: export function logForDiagnosticsNoPII( 12: level: DiagnosticLogLevel, 13: event: string, 14: data?: Record<string, unknown>, 15: ): void { 16: const logFile = getDiagnosticLogFile() 17: if (!logFile) { 18: return 19: } 20: const entry: DiagnosticLogEntry = { 21: timestamp: new Date().toISOString(), 22: level, 23: event, 24: data: data ?? {}, 25: } 26: const fs = getFsImplementation() 27: const line = jsonStringify(entry) + '\n' 28: try { 29: fs.appendFileSync(logFile, line) 30: } catch { 31: try { 32: fs.mkdirSync(dirname(logFile)) 33: fs.appendFileSync(logFile, line) 34: } catch { 35: } 36: } 37: } 38: function getDiagnosticLogFile(): string | undefined { 39: return process.env.CLAUDE_CODE_DIAGNOSTICS_FILE 40: } 41: export async function withDiagnosticsTiming<T>( 42: event: string, 43: fn: () => Promise<T>, 44: getData?: (result: T) => Record<string, unknown>, 45: ): Promise<T> { 46: const startTime = Date.now() 47: logForDiagnosticsNoPII('info', `${event}_started`) 48: try { 49: const result = await fn() 50: const additionalData = getData ? getData(result) : {} 51: logForDiagnosticsNoPII('info', `${event}_completed`, { 52: duration_ms: Date.now() - startTime, 53: ...additionalData, 54: }) 55: return result 56: } catch (error) { 57: logForDiagnosticsNoPII('error', `${event}_failed`, { 58: duration_ms: Date.now() - startTime, 59: }) 60: throw error 61: } 62: }

File: src/utils/diff.ts

typescript 1: import { type StructuredPatchHunk, structuredPatch } from 'diff' 2: import { logEvent } from 'src/services/analytics/index.js' 3: import { getLocCounter } from '../bootstrap/state.js' 4: import { addToTotalLinesChanged } from '../cost-tracker.js' 5: import type { FileEdit } from '../tools/FileEditTool/types.js' 6: import { count } from './array.js' 7: import { convertLeadingTabsToSpaces } from './file.js' 8: export const CONTEXT_LINES = 3 9: export const DIFF_TIMEOUT_MS = 5_000 10: export function adjustHunkLineNumbers( 11: hunks: StructuredPatchHunk[], 12: offset: number, 13: ): StructuredPatchHunk[] { 14: if (offset === 0) return hunks 15: return hunks.map(h => ({ 16: ...h, 17: oldStart: h.oldStart + offset, 18: newStart: h.newStart + offset, 19: })) 20: } 21: const AMPERSAND_TOKEN = '<<:AMPERSAND_TOKEN:>>' 22: const DOLLAR_TOKEN = '<<:DOLLAR_TOKEN:>>' 23: function escapeForDiff(s: string): string { 24: return s.replaceAll('&', AMPERSAND_TOKEN).replaceAll('$', DOLLAR_TOKEN) 25: } 26: function unescapeFromDiff(s: string): string { 27: return s.replaceAll(AMPERSAND_TOKEN, '&').replaceAll(DOLLAR_TOKEN, '$') 28: } 29: export function countLinesChanged( 30: patch: StructuredPatchHunk[], 31: newFileContent?: string, 32: ): void { 33: let numAdditions = 0 34: let numRemovals = 0 35: if (patch.length === 0 && newFileContent) { 36: numAdditions = newFileContent.split(/\r?\n/).length 37: } else { 38: numAdditions = patch.reduce( 39: (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), 40: 0, 41: ) 42: numRemovals = patch.reduce( 43: (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 44: 0, 45: ) 46: } 47: addToTotalLinesChanged(numAdditions, numRemovals) 48: getLocCounter()?.add(numAdditions, { type: 'added' }) 49: getLocCounter()?.add(numRemovals, { type: 'removed' }) 50: logEvent('tengu_file_changed', { 51: lines_added: numAdditions, 52: lines_removed: numRemovals, 53: }) 54: } 55: export function getPatchFromContents({ 56: filePath, 57: oldContent, 58: newContent, 59: ignoreWhitespace = false, 60: singleHunk = false, 61: }: { 62: filePath: string 63: oldContent: string 64: newContent: string 65: ignoreWhitespace?: boolean 66: singleHunk?: boolean 67: }): StructuredPatchHunk[] { 68: const result = structuredPatch( 69: filePath, 70: filePath, 71: escapeForDiff(oldContent), 72: escapeForDiff(newContent), 73: undefined, 74: undefined, 75: { 76: ignoreWhitespace, 77: context: singleHunk ? 100_000 : CONTEXT_LINES, 78: timeout: DIFF_TIMEOUT_MS, 79: }, 80: ) 81: if (!result) { 82: return [] 83: } 84: return result.hunks.map(_ => ({ 85: ..._, 86: lines: _.lines.map(unescapeFromDiff), 87: })) 88: } 89: export function getPatchForDisplay({ 90: filePath, 91: fileContents, 92: edits, 93: ignoreWhitespace = false, 94: }: { 95: filePath: string 96: fileContents: string 97: edits: FileEdit[] 98: ignoreWhitespace?: boolean 99: }): StructuredPatchHunk[] { 100: const preparedFileContents = escapeForDiff( 101: convertLeadingTabsToSpaces(fileContents), 102: ) 103: const result = structuredPatch( 104: filePath, 105: filePath, 106: preparedFileContents, 107: edits.reduce((p, edit) => { 108: const { old_string, new_string } = edit 109: const replace_all = 'replace_all' in edit ? edit.replace_all : false 110: const escapedOldString = escapeForDiff( 111: convertLeadingTabsToSpaces(old_string), 112: ) 113: const escapedNewString = escapeForDiff( 114: convertLeadingTabsToSpaces(new_string), 115: ) 116: if (replace_all) { 117: return p.replaceAll(escapedOldString, () => escapedNewString) 118: } else { 119: return p.replace(escapedOldString, () => escapedNewString) 120: } 121: }, preparedFileContents), 122: undefined, 123: undefined, 124: { 125: context: CONTEXT_LINES, 126: ignoreWhitespace, 127: timeout: DIFF_TIMEOUT_MS, 128: }, 129: ) 130: if (!result) { 131: return [] 132: } 133: return result.hunks.map(_ => ({ 134: ..._, 135: lines: _.lines.map(unescapeFromDiff), 136: })) 137: }

File: src/utils/directMemberMessage.ts

typescript 1: import type { AppState } from '../state/AppState.js' 2: export function parseDirectMemberMessage(input: string): { 3: recipientName: string 4: message: string 5: } | null { 6: const match = input.match(/^@([\w-]+)\s+(.+)$/s) 7: if (!match) return null 8: const [, recipientName, message] = match 9: if (!recipientName || !message) return null 10: const trimmedMessage = message.trim() 11: if (!trimmedMessage) return null 12: return { recipientName, message: trimmedMessage } 13: } 14: export type DirectMessageResult = 15: | { success: true; recipientName: string } 16: | { 17: success: false 18: error: 'no_team_context' | 'unknown_recipient' 19: recipientName?: string 20: } 21: type WriteToMailboxFn = ( 22: recipientName: string, 23: message: { from: string; text: string; timestamp: string }, 24: teamName: string, 25: ) => Promise<void> 26: export async function sendDirectMemberMessage( 27: recipientName: string, 28: message: string, 29: teamContext: AppState['teamContext'], 30: writeToMailbox?: WriteToMailboxFn, 31: ): Promise<DirectMessageResult> { 32: if (!teamContext || !writeToMailbox) { 33: return { success: false, error: 'no_team_context' } 34: } 35: const member = Object.values(teamContext.teammates ?? {}).find( 36: t => t.name === recipientName, 37: ) 38: if (!member) { 39: return { success: false, error: 'unknown_recipient', recipientName } 40: } 41: await writeToMailbox( 42: recipientName, 43: { 44: from: 'user', 45: text: message, 46: timestamp: new Date().toISOString(), 47: }, 48: teamContext.teamName, 49: ) 50: return { success: true, recipientName } 51: }

File: src/utils/displayTags.ts

typescript 1: const XML_TAG_BLOCK_PATTERN = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\n?/g 2: export function stripDisplayTags(text: string): string { 3: const result = text.replace(XML_TAG_BLOCK_PATTERN, '').trim() 4: return result || text 5: } 6: /** 7: * Like stripDisplayTags but returns empty string when all content is tags. 8: * Used by getLogDisplayTitle to detect command-only prompts (e.g. /clear) 9: * so they can fall through to the next title fallback, and by extractTitleText 10: * to skip pure-XML messages during bridge title derivation. 11: */ 12: export function stripDisplayTagsAllowEmpty(text: string): string { 13: return text.replace(XML_TAG_BLOCK_PATTERN, '').trim() 14: } 15: const IDE_CONTEXT_TAGS_PATTERN = 16: /<(ide_opened_file|ide_selection)(?:\s[^>]*)?>[\s\S]*?<\/\1>\n?/g 17: /** 18: * Strip only IDE-injected context tags (ide_opened_file, ide_selection). 19: * Used by textForResubmit so UP-arrow resubmit preserves user-typed content 20: * including lowercase HTML like `<code>foo</code>` while dropping IDE noise. 21: */ 22: export function stripIdeContextTags(text: string): string { 23: return text.replace(IDE_CONTEXT_TAGS_PATTERN, '').trim() 24: }

File: src/utils/doctorContextWarnings.ts

typescript 1: import { roughTokenCountEstimation } from '../services/tokenEstimation.js' 2: import type { Tool, ToolPermissionContext } from '../Tool.js' 3: import type { AgentDefinitionsResult } from '../tools/AgentTool/loadAgentsDir.js' 4: import { countMcpToolTokens } from './analyzeContext.js' 5: import { 6: getLargeMemoryFiles, 7: getMemoryFiles, 8: MAX_MEMORY_CHARACTER_COUNT, 9: } from './claudemd.js' 10: import { getMainLoopModel } from './model/model.js' 11: import { permissionRuleValueToString } from './permissions/permissionRuleParser.js' 12: import { detectUnreachableRules } from './permissions/shadowedRuleDetection.js' 13: import { SandboxManager } from './sandbox/sandbox-adapter.js' 14: import { 15: AGENT_DESCRIPTIONS_THRESHOLD, 16: getAgentDescriptionsTotalTokens, 17: } from './statusNoticeHelpers.js' 18: import { plural } from './stringUtils.js' 19: const MCP_TOOLS_THRESHOLD = 25_000 20: export type ContextWarning = { 21: type: 22: | 'claudemd_files' 23: | 'agent_descriptions' 24: | 'mcp_tools' 25: | 'unreachable_rules' 26: severity: 'warning' | 'error' 27: message: string 28: details: string[] 29: currentValue: number 30: threshold: number 31: } 32: export type ContextWarnings = { 33: claudeMdWarning: ContextWarning | null 34: agentWarning: ContextWarning | null 35: mcpWarning: ContextWarning | null 36: unreachableRulesWarning: ContextWarning | null 37: } 38: async function checkClaudeMdFiles(): Promise<ContextWarning | null> { 39: const largeFiles = getLargeMemoryFiles(await getMemoryFiles()) 40: if (largeFiles.length === 0) { 41: return null 42: } 43: const details = largeFiles 44: .sort((a, b) => b.content.length - a.content.length) 45: .map(file => `${file.path}: ${file.content.length.toLocaleString()} chars`) 46: const message = 47: largeFiles.length === 1 48: ? `Large CLAUDE.md file detected (${largeFiles[0]!.content.length.toLocaleString()} chars > ${MAX_MEMORY_CHARACTER_COUNT.toLocaleString()})` 49: : `${largeFiles.length} large CLAUDE.md files detected (each > ${MAX_MEMORY_CHARACTER_COUNT.toLocaleString()} chars)` 50: return { 51: type: 'claudemd_files', 52: severity: 'warning', 53: message, 54: details, 55: currentValue: largeFiles.length, 56: threshold: MAX_MEMORY_CHARACTER_COUNT, 57: } 58: } 59: async function checkAgentDescriptions( 60: agentInfo: AgentDefinitionsResult | null, 61: ): Promise<ContextWarning | null> { 62: if (!agentInfo) { 63: return null 64: } 65: const totalTokens = getAgentDescriptionsTotalTokens(agentInfo) 66: if (totalTokens <= AGENT_DESCRIPTIONS_THRESHOLD) { 67: return null 68: } 69: const agentTokens = agentInfo.activeAgents 70: .filter(a => a.source !== 'built-in') 71: .map(agent => { 72: const description = `${agent.agentType}: ${agent.whenToUse}` 73: return { 74: name: agent.agentType, 75: tokens: roughTokenCountEstimation(description), 76: } 77: }) 78: .sort((a, b) => b.tokens - a.tokens) 79: const details = agentTokens 80: .slice(0, 5) 81: .map(agent => `${agent.name}: ~${agent.tokens.toLocaleString()} tokens`) 82: if (agentTokens.length > 5) { 83: details.push(`(${agentTokens.length - 5} more custom agents)`) 84: } 85: return { 86: type: 'agent_descriptions', 87: severity: 'warning', 88: message: `Large agent descriptions (~${totalTokens.toLocaleString()} tokens > ${AGENT_DESCRIPTIONS_THRESHOLD.toLocaleString()})`, 89: details, 90: currentValue: totalTokens, 91: threshold: AGENT_DESCRIPTIONS_THRESHOLD, 92: } 93: } 94: async function checkMcpTools( 95: tools: Tool[], 96: getToolPermissionContext: () => Promise<ToolPermissionContext>, 97: agentInfo: AgentDefinitionsResult | null, 98: ): Promise<ContextWarning | null> { 99: const mcpTools = tools.filter(tool => tool.isMcp) 100: if (mcpTools.length === 0) { 101: return null 102: } 103: try { 104: const model = getMainLoopModel() 105: const { mcpToolTokens, mcpToolDetails } = await countMcpToolTokens( 106: tools, 107: getToolPermissionContext, 108: agentInfo, 109: model, 110: ) 111: if (mcpToolTokens <= MCP_TOOLS_THRESHOLD) { 112: return null 113: } 114: const toolsByServer = new Map<string, { count: number; tokens: number }>() 115: for (const tool of mcpToolDetails) { 116: const parts = tool.name.split('__') 117: const serverName = parts[1] || 'unknown' 118: const current = toolsByServer.get(serverName) || { count: 0, tokens: 0 } 119: toolsByServer.set(serverName, { 120: count: current.count + 1, 121: tokens: current.tokens + tool.tokens, 122: }) 123: } 124: const sortedServers = Array.from(toolsByServer.entries()).sort( 125: (a, b) => b[1].tokens - a[1].tokens, 126: ) 127: const details = sortedServers 128: .slice(0, 5) 129: .map( 130: ([name, info]) => 131: `${name}: ${info.count} tools (~${info.tokens.toLocaleString()} tokens)`, 132: ) 133: if (sortedServers.length > 5) { 134: details.push(`(${sortedServers.length - 5} more servers)`) 135: } 136: return { 137: type: 'mcp_tools', 138: severity: 'warning', 139: message: `Large MCP tools context (~${mcpToolTokens.toLocaleString()} tokens > ${MCP_TOOLS_THRESHOLD.toLocaleString()})`, 140: details, 141: currentValue: mcpToolTokens, 142: threshold: MCP_TOOLS_THRESHOLD, 143: } 144: } catch (_error) { 145: const estimatedTokens = mcpTools.reduce((total, tool) => { 146: const chars = (tool.name?.length || 0) + tool.description.length 147: return total + roughTokenCountEstimation(chars.toString()) 148: }, 0) 149: if (estimatedTokens <= MCP_TOOLS_THRESHOLD) { 150: return null 151: } 152: return { 153: type: 'mcp_tools', 154: severity: 'warning', 155: message: `Large MCP tools context (~${estimatedTokens.toLocaleString()} tokens estimated > ${MCP_TOOLS_THRESHOLD.toLocaleString()})`, 156: details: [ 157: `${mcpTools.length} MCP tools detected (token count estimated)`, 158: ], 159: currentValue: estimatedTokens, 160: threshold: MCP_TOOLS_THRESHOLD, 161: } 162: } 163: } 164: async function checkUnreachableRules( 165: getToolPermissionContext: () => Promise<ToolPermissionContext>, 166: ): Promise<ContextWarning | null> { 167: const context = await getToolPermissionContext() 168: const sandboxAutoAllowEnabled = 169: SandboxManager.isSandboxingEnabled() && 170: SandboxManager.isAutoAllowBashIfSandboxedEnabled() 171: const unreachable = detectUnreachableRules(context, { 172: sandboxAutoAllowEnabled, 173: }) 174: if (unreachable.length === 0) { 175: return null 176: } 177: const details = unreachable.flatMap(r => [ 178: `${permissionRuleValueToString(r.rule.ruleValue)}: ${r.reason}`, 179: ` Fix: ${r.fix}`, 180: ]) 181: return { 182: type: 'unreachable_rules', 183: severity: 'warning', 184: message: `${unreachable.length} ${plural(unreachable.length, 'unreachable permission rule')} detected`, 185: details, 186: currentValue: unreachable.length, 187: threshold: 0, 188: } 189: } 190: export async function checkContextWarnings( 191: tools: Tool[], 192: agentInfo: AgentDefinitionsResult | null, 193: getToolPermissionContext: () => Promise<ToolPermissionContext>, 194: ): Promise<ContextWarnings> { 195: const [claudeMdWarning, agentWarning, mcpWarning, unreachableRulesWarning] = 196: await Promise.all([ 197: checkClaudeMdFiles(), 198: checkAgentDescriptions(agentInfo), 199: checkMcpTools(tools, getToolPermissionContext, agentInfo), 200: checkUnreachableRules(getToolPermissionContext), 201: ]) 202: return { 203: claudeMdWarning, 204: agentWarning, 205: mcpWarning, 206: unreachableRulesWarning, 207: } 208: }

File: src/utils/doctorDiagnostic.ts

typescript 1: import { execa } from 'execa' 2: import { readFile, realpath } from 'fs/promises' 3: import { homedir } from 'os' 4: import { delimiter, join, posix, win32 } from 'path' 5: import { checkGlobalInstallPermissions } from './autoUpdater.js' 6: import { isInBundledMode } from './bundledMode.js' 7: import { 8: formatAutoUpdaterDisabledReason, 9: getAutoUpdaterDisabledReason, 10: getGlobalConfig, 11: type InstallMethod, 12: } from './config.js' 13: import { getCwd } from './cwd.js' 14: import { isEnvTruthy } from './envUtils.js' 15: import { execFileNoThrow } from './execFileNoThrow.js' 16: import { getFsImplementation } from './fsOperations.js' 17: import { 18: getShellType, 19: isRunningFromLocalInstallation, 20: localInstallationExists, 21: } from './localInstaller.js' 22: import { 23: detectApk, 24: detectAsdf, 25: detectDeb, 26: detectHomebrew, 27: detectMise, 28: detectPacman, 29: detectRpm, 30: detectWinget, 31: getPackageManager, 32: } from './nativeInstaller/packageManagers.js' 33: import { getPlatform } from './platform.js' 34: import { getRipgrepStatus } from './ripgrep.js' 35: import { SandboxManager } from './sandbox/sandbox-adapter.js' 36: import { getManagedFilePath } from './settings/managedPath.js' 37: import { CUSTOMIZATION_SURFACES } from './settings/types.js' 38: import { 39: findClaudeAlias, 40: findValidClaudeAlias, 41: getShellConfigPaths, 42: } from './shellConfig.js' 43: import { jsonParse } from './slowOperations.js' 44: import { which } from './which.js' 45: export type InstallationType = 46: | 'npm-global' 47: | 'npm-local' 48: | 'native' 49: | 'package-manager' 50: | 'development' 51: | 'unknown' 52: export type DiagnosticInfo = { 53: installationType: InstallationType 54: version: string 55: installationPath: string 56: invokedBinary: string 57: configInstallMethod: InstallMethod | 'not set' 58: autoUpdates: string 59: hasUpdatePermissions: boolean | null 60: multipleInstallations: Array<{ type: string; path: string }> 61: warnings: Array<{ issue: string; fix: string }> 62: recommendation?: string 63: packageManager?: string 64: ripgrepStatus: { 65: working: boolean 66: mode: 'system' | 'builtin' | 'embedded' 67: systemPath: string | null 68: } 69: } 70: function getNormalizedPaths(): [invokedPath: string, execPath: string] { 71: let invokedPath = process.argv[1] || '' 72: let execPath = process.execPath || process.argv[0] || '' 73: // On Windows, convert backslashes to forward slashes for consistent path matching 74: if (getPlatform() === 'windows') { 75: invokedPath = invokedPath.split(win32.sep).join(posix.sep) 76: execPath = execPath.split(win32.sep).join(posix.sep) 77: } 78: return [invokedPath, execPath] 79: } 80: export async function getCurrentInstallationType(): Promise<InstallationType> { 81: if (process.env.NODE_ENV === 'development') { 82: return 'development' 83: } 84: const [invokedPath] = getNormalizedPaths() 85: if (isInBundledMode()) { 86: if ( 87: detectHomebrew() || 88: detectWinget() || 89: detectMise() || 90: detectAsdf() || 91: (await detectPacman()) || 92: (await detectDeb()) || 93: (await detectRpm()) || 94: (await detectApk()) 95: ) { 96: return 'package-manager' 97: } 98: return 'native' 99: } 100: if (isRunningFromLocalInstallation()) { 101: return 'npm-local' 102: } 103: const npmGlobalPaths = [ 104: '/usr/local/lib/node_modules', 105: '/usr/lib/node_modules', 106: '/opt/homebrew/lib/node_modules', 107: '/opt/homebrew/bin', 108: '/usr/local/bin', 109: '/.nvm/versions/node/', 110: ] 111: if (npmGlobalPaths.some(path => invokedPath.includes(path))) { 112: return 'npm-global' 113: } 114: if (invokedPath.includes('/npm/') || invokedPath.includes('/nvm/')) { 115: return 'npm-global' 116: } 117: const npmConfigResult = await execa('npm config get prefix', { 118: shell: true, 119: reject: false, 120: }) 121: const globalPrefix = 122: npmConfigResult.exitCode === 0 ? npmConfigResult.stdout.trim() : null 123: if (globalPrefix && invokedPath.startsWith(globalPrefix)) { 124: return 'npm-global' 125: } 126: return 'unknown' 127: } 128: async function getInstallationPath(): Promise<string> { 129: if (process.env.NODE_ENV === 'development') { 130: return getCwd() 131: } 132: if (isInBundledMode()) { 133: try { 134: return await realpath(process.execPath) 135: } catch { 136: } 137: try { 138: const path = await which('claude') 139: if (path) { 140: return path 141: } 142: } catch { 143: } 144: try { 145: await getFsImplementation().stat(join(homedir(), '.local/bin/claude')) 146: return join(homedir(), '.local/bin/claude') 147: } catch { 148: } 149: return 'native' 150: } 151: try { 152: return process.argv[0] || 'unknown' 153: } catch { 154: return 'unknown' 155: } 156: } 157: export function getInvokedBinary(): string { 158: try { 159: if (isInBundledMode()) { 160: return process.execPath || 'unknown' 161: } 162: return process.argv[1] || 'unknown' 163: } catch { 164: return 'unknown' 165: } 166: } 167: async function detectMultipleInstallations(): Promise< 168: Array<{ type: string; path: string }> 169: > { 170: const fs = getFsImplementation() 171: const installations: Array<{ type: string; path: string }> = [] 172: const localPath = join(homedir(), '.claude', 'local') 173: if (await localInstallationExists()) { 174: installations.push({ type: 'npm-local', path: localPath }) 175: } 176: const packagesToCheck = ['@anthropic-ai/claude-code'] 177: if (MACRO.PACKAGE_URL && MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code') { 178: packagesToCheck.push(MACRO.PACKAGE_URL) 179: } 180: const npmResult = await execFileNoThrow('npm', [ 181: '-g', 182: 'config', 183: 'get', 184: 'prefix', 185: ]) 186: if (npmResult.code === 0 && npmResult.stdout) { 187: const npmPrefix = npmResult.stdout.trim() 188: const isWindows = getPlatform() === 'windows' 189: const globalBinPath = isWindows 190: ? join(npmPrefix, 'claude') 191: : join(npmPrefix, 'bin', 'claude') 192: let globalBinExists = false 193: try { 194: await fs.stat(globalBinPath) 195: globalBinExists = true 196: } catch { 197: } 198: if (globalBinExists) { 199: let isCurrentHomebrewInstallation = false 200: try { 201: const realPath = await realpath(globalBinPath) 202: if (realPath.includes('/Caskroom/')) { 203: isCurrentHomebrewInstallation = detectHomebrew() 204: } 205: } catch { 206: } 207: if (!isCurrentHomebrewInstallation) { 208: installations.push({ type: 'npm-global', path: globalBinPath }) 209: } 210: } else { 211: for (const packageName of packagesToCheck) { 212: const globalPackagePath = isWindows 213: ? join(npmPrefix, 'node_modules', packageName) 214: : join(npmPrefix, 'lib', 'node_modules', packageName) 215: try { 216: await fs.stat(globalPackagePath) 217: installations.push({ 218: type: 'npm-global-orphan', 219: path: globalPackagePath, 220: }) 221: } catch { 222: } 223: } 224: } 225: } 226: const nativeBinPath = join(homedir(), '.local', 'bin', 'claude') 227: try { 228: await fs.stat(nativeBinPath) 229: installations.push({ type: 'native', path: nativeBinPath }) 230: } catch { 231: } 232: const config = getGlobalConfig() 233: if (config.installMethod === 'native') { 234: const nativeDataPath = join(homedir(), '.local', 'share', 'claude') 235: try { 236: await fs.stat(nativeDataPath) 237: if (!installations.some(i => i.type === 'native')) { 238: installations.push({ type: 'native', path: nativeDataPath }) 239: } 240: } catch { 241: } 242: } 243: return installations 244: } 245: async function detectConfigurationIssues( 246: type: InstallationType, 247: ): Promise<Array<{ issue: string; fix: string }>> { 248: const warnings: Array<{ issue: string; fix: string }> = [] 249: try { 250: const raw = await readFile( 251: join(getManagedFilePath(), 'managed-settings.json'), 252: 'utf-8', 253: ) 254: const parsed: unknown = jsonParse(raw) 255: const field = 256: parsed && typeof parsed === 'object' 257: ? (parsed as Record<string, unknown>).strictPluginOnlyCustomization 258: : undefined 259: if (field !== undefined && typeof field !== 'boolean') { 260: if (!Array.isArray(field)) { 261: warnings.push({ 262: issue: `managed-settings.json: strictPluginOnlyCustomization has an invalid value (expected true or an array, got ${typeof field})`, 263: fix: `The field is silently ignored (schema .catch rescues it). Set it to true, or an array of: ${CUSTOMIZATION_SURFACES.join(', ')}.`, 264: }) 265: } else { 266: const unknown = field.filter( 267: x => 268: typeof x === 'string' && 269: !(CUSTOMIZATION_SURFACES as readonly string[]).includes(x), 270: ) 271: if (unknown.length > 0) { 272: warnings.push({ 273: issue: `managed-settings.json: strictPluginOnlyCustomization has ${unknown.length} value(s) this client doesn't recognize: ${unknown.map(String).join(', ')}`, 274: fix: `These are silently ignored (forwards-compat). Known surfaces for this version: ${CUSTOMIZATION_SURFACES.join(', ')}. Either remove them, or this client is older than the managed-settings intended.`, 275: }) 276: } 277: } 278: } 279: } catch { 280: } 281: const config = getGlobalConfig() 282: if (type === 'development') { 283: return warnings 284: } 285: if (type === 'native') { 286: const path = process.env.PATH || '' 287: const pathDirectories = path.split(delimiter) 288: const homeDir = homedir() 289: const localBinPath = join(homeDir, '.local', 'bin') 290: let normalizedLocalBinPath = localBinPath 291: if (getPlatform() === 'windows') { 292: normalizedLocalBinPath = localBinPath.split(win32.sep).join(posix.sep) 293: } 294: const localBinInPath = pathDirectories.some(dir => { 295: let normalizedDir = dir 296: if (getPlatform() === 'windows') { 297: normalizedDir = dir.split(win32.sep).join(posix.sep) 298: } 299: const trimmedDir = normalizedDir.replace(/\/+$/, '') 300: const trimmedRawDir = dir.replace(/[/\\]+$/, '') 301: return ( 302: trimmedDir === normalizedLocalBinPath || 303: trimmedRawDir === '~/.local/bin' || 304: trimmedRawDir === '$HOME/.local/bin' 305: ) 306: }) 307: if (!localBinInPath) { 308: const isWindows = getPlatform() === 'windows' 309: if (isWindows) { 310: const windowsLocalBinPath = localBinPath 311: .split(posix.sep) 312: .join(win32.sep) 313: warnings.push({ 314: issue: `Native installation exists but ${windowsLocalBinPath} is not in your PATH`, 315: fix: `Add it by opening: System Properties → Environment Variables → Edit User PATH → New → Add the path above. Then restart your terminal.`, 316: }) 317: } else { 318: const shellType = getShellType() 319: const configPaths = getShellConfigPaths() 320: const configFile = configPaths[shellType as keyof typeof configPaths] 321: const displayPath = configFile 322: ? configFile.replace(homedir(), '~') 323: : 'your shell config file' 324: warnings.push({ 325: issue: 326: 'Native installation exists but ~/.local/bin is not in your PATH', 327: fix: `Run: echo 'export PATH="$HOME/.local/bin:$PATH"' >> ${displayPath} then open a new terminal or run: source ${displayPath}`, 328: }) 329: } 330: } 331: } 332: if (!isEnvTruthy(process.env.DISABLE_INSTALLATION_CHECKS)) { 333: if (type === 'npm-local' && config.installMethod !== 'local') { 334: warnings.push({ 335: issue: `Running from local installation but config install method is '${config.installMethod}'`, 336: fix: 'Consider using native installation: claude install', 337: }) 338: } 339: if (type === 'native' && config.installMethod !== 'native') { 340: warnings.push({ 341: issue: `Running native installation but config install method is '${config.installMethod}'`, 342: fix: 'Run claude install to update configuration', 343: }) 344: } 345: } 346: if (type === 'npm-global' && (await localInstallationExists())) { 347: warnings.push({ 348: issue: 'Local installation exists but not being used', 349: fix: 'Consider using native installation: claude install', 350: }) 351: } 352: const existingAlias = await findClaudeAlias() 353: const validAlias = await findValidClaudeAlias() 354: if (type === 'npm-local') { 355: const whichResult = await which('claude') 356: const claudeInPath = !!whichResult 357: if (!claudeInPath && !validAlias) { 358: if (existingAlias) { 359: warnings.push({ 360: issue: 'Local installation not accessible', 361: fix: `Alias exists but points to invalid target: ${existingAlias}. Update alias: alias claude="~/.claude/local/claude"`, 362: }) 363: } else { 364: warnings.push({ 365: issue: 'Local installation not accessible', 366: fix: 'Create alias: alias claude="~/.claude/local/claude"', 367: }) 368: } 369: } 370: } 371: return warnings 372: } 373: export function detectLinuxGlobPatternWarnings(): Array<{ 374: issue: string 375: fix: string 376: }> { 377: if (getPlatform() !== 'linux') { 378: return [] 379: } 380: const warnings: Array<{ issue: string; fix: string }> = [] 381: const globPatterns = SandboxManager.getLinuxGlobPatternWarnings() 382: if (globPatterns.length > 0) { 383: const displayPatterns = globPatterns.slice(0, 3).join(', ') 384: const remaining = globPatterns.length - 3 385: const patternList = 386: remaining > 0 ? `${displayPatterns} (${remaining} more)` : displayPatterns 387: warnings.push({ 388: issue: `Glob patterns in sandbox permission rules are not fully supported on Linux`, 389: fix: `Found ${globPatterns.length} pattern(s): ${patternList}. On Linux, glob patterns in Edit/Read rules will be ignored.`, 390: }) 391: } 392: return warnings 393: } 394: export async function getDoctorDiagnostic(): Promise<DiagnosticInfo> { 395: const installationType = await getCurrentInstallationType() 396: const version = 397: typeof MACRO !== 'undefined' && MACRO.VERSION ? MACRO.VERSION : 'unknown' 398: const installationPath = await getInstallationPath() 399: const invokedBinary = getInvokedBinary() 400: const multipleInstallations = await detectMultipleInstallations() 401: const warnings = await detectConfigurationIssues(installationType) 402: warnings.push(...detectLinuxGlobPatternWarnings()) 403: if (installationType === 'native') { 404: const npmInstalls = multipleInstallations.filter( 405: i => 406: i.type === 'npm-global' || 407: i.type === 'npm-global-orphan' || 408: i.type === 'npm-local', 409: ) 410: const isWindows = getPlatform() === 'windows' 411: for (const install of npmInstalls) { 412: if (install.type === 'npm-global') { 413: let uninstallCmd = 'npm -g uninstall @anthropic-ai/claude-code' 414: if ( 415: MACRO.PACKAGE_URL && 416: MACRO.PACKAGE_URL !== '@anthropic-ai/claude-code' 417: ) { 418: uninstallCmd += ` && npm -g uninstall ${MACRO.PACKAGE_URL}` 419: } 420: warnings.push({ 421: issue: `Leftover npm global installation at ${install.path}`, 422: fix: `Run: ${uninstallCmd}`, 423: }) 424: } else if (install.type === 'npm-global-orphan') { 425: warnings.push({ 426: issue: `Orphaned npm global package at ${install.path}`, 427: fix: isWindows 428: ? `Run: rmdir /s /q "${install.path}"` 429: : `Run: rm -rf ${install.path}`, 430: }) 431: } else if (install.type === 'npm-local') { 432: warnings.push({ 433: issue: `Leftover npm local installation at ${install.path}`, 434: fix: isWindows 435: ? `Run: rmdir /s /q "${install.path}"` 436: : `Run: rm -rf ${install.path}`, 437: }) 438: } 439: } 440: } 441: const config = getGlobalConfig() 442: const configInstallMethod = config.installMethod || 'not set' 443: let hasUpdatePermissions: boolean | null = null 444: if (installationType === 'npm-global') { 445: const permCheck = await checkGlobalInstallPermissions() 446: hasUpdatePermissions = permCheck.hasPermissions 447: if (!hasUpdatePermissions && !getAutoUpdaterDisabledReason()) { 448: warnings.push({ 449: issue: 'Insufficient permissions for auto-updates', 450: fix: 'Do one of: (1) Re-install node without sudo, or (2) Use `claude install` for native installation', 451: }) 452: } 453: } 454: const ripgrepStatusRaw = getRipgrepStatus() 455: const ripgrepStatus = { 456: working: ripgrepStatusRaw.working ?? true, 457: mode: ripgrepStatusRaw.mode, 458: systemPath: 459: ripgrepStatusRaw.mode === 'system' ? ripgrepStatusRaw.path : null, 460: } 461: const packageManager = 462: installationType === 'package-manager' 463: ? await getPackageManager() 464: : undefined 465: const diagnostic: DiagnosticInfo = { 466: installationType, 467: version, 468: installationPath, 469: invokedBinary, 470: configInstallMethod, 471: autoUpdates: (() => { 472: const reason = getAutoUpdaterDisabledReason() 473: return reason 474: ? `disabled (${formatAutoUpdaterDisabledReason(reason)})` 475: : 'enabled' 476: })(), 477: hasUpdatePermissions, 478: multipleInstallations, 479: warnings, 480: packageManager, 481: ripgrepStatus, 482: } 483: return diagnostic 484: }

File: src/utils/earlyInput.ts

typescript 1: import { lastGrapheme } from './intl.js' 2: let earlyInputBuffer = '' 3: // Flag to track if we're currently capturing 4: let isCapturing = false 5: let readableHandler: (() => void) | null = null 6: export function startCapturingEarlyInput(): void { 7: if ( 8: !process.stdin.isTTY || 9: isCapturing || 10: process.argv.includes('-p') || 11: process.argv.includes('--print') 12: ) { 13: return 14: } 15: isCapturing = true 16: earlyInputBuffer = '' 17: // Set stdin to raw mode and use 'readable' event like Ink does 18: try { 19: process.stdin.setEncoding('utf8') 20: process.stdin.setRawMode(true) 21: process.stdin.ref() 22: readableHandler = () => { 23: let chunk = process.stdin.read() 24: while (chunk !== null) { 25: if (typeof chunk === 'string') { 26: processChunk(chunk) 27: } 28: chunk = process.stdin.read() 29: } 30: } 31: process.stdin.on('readable', readableHandler) 32: } catch { 33: isCapturing = false 34: } 35: } 36: function processChunk(str: string): void { 37: let i = 0 38: while (i < str.length) { 39: const char = str[i]! 40: const code = char.charCodeAt(0) 41: if (code === 3) { 42: stopCapturingEarlyInput() 43: process.exit(130) 44: return 45: } 46: if (code === 4) { 47: stopCapturingEarlyInput() 48: return 49: } 50: if (code === 127 || code === 8) { 51: if (earlyInputBuffer.length > 0) { 52: const last = lastGrapheme(earlyInputBuffer) 53: earlyInputBuffer = earlyInputBuffer.slice(0, -(last.length || 1)) 54: } 55: i++ 56: continue 57: } 58: if (code === 27) { 59: i++ 60: while ( 61: i < str.length && 62: !(str.charCodeAt(i) >= 64 && str.charCodeAt(i) <= 126) 63: ) { 64: i++ 65: } 66: if (i < str.length) i++ 67: continue 68: } 69: if (code < 32 && code !== 9 && code !== 10 && code !== 13) { 70: i++ 71: continue 72: } 73: if (code === 13) { 74: earlyInputBuffer += '\n' 75: i++ 76: continue 77: } 78: earlyInputBuffer += char 79: i++ 80: } 81: } 82: export function stopCapturingEarlyInput(): void { 83: if (!isCapturing) { 84: return 85: } 86: isCapturing = false 87: if (readableHandler) { 88: process.stdin.removeListener('readable', readableHandler) 89: readableHandler = null 90: } 91: } 92: export function consumeEarlyInput(): string { 93: stopCapturingEarlyInput() 94: const input = earlyInputBuffer.trim() 95: earlyInputBuffer = '' 96: return input 97: } 98: export function hasEarlyInput(): boolean { 99: return earlyInputBuffer.trim().length > 0 100: } 101: export function seedEarlyInput(text: string): void { 102: earlyInputBuffer = text 103: } 104: export function isCapturingEarlyInput(): boolean { 105: return isCapturing 106: }

File: src/utils/editor.ts

typescript 1: import { 2: type SpawnOptions, 3: type SpawnSyncOptions, 4: spawn, 5: spawnSync, 6: } from 'child_process' 7: import memoize from 'lodash-es/memoize.js' 8: import { basename } from 'path' 9: import instances from '../ink/instances.js' 10: import { logForDebugging } from './debug.js' 11: import { whichSync } from './which.js' 12: function isCommandAvailable(command: string): boolean { 13: return !!whichSync(command) 14: } 15: const GUI_EDITORS = [ 16: 'code', 17: 'cursor', 18: 'windsurf', 19: 'codium', 20: 'subl', 21: 'atom', 22: 'gedit', 23: 'notepad++', 24: 'notepad', 25: ] 26: const PLUS_N_EDITORS = /\b(vi|vim|nvim|nano|emacs|pico|micro|helix|hx)\b/ 27: const VSCODE_FAMILY = new Set(['code', 'cursor', 'windsurf', 'codium']) 28: export function classifyGuiEditor(editor: string): string | undefined { 29: const base = basename(editor.split(' ')[0] ?? '') 30: return GUI_EDITORS.find(g => base.includes(g)) 31: } 32: /** 33: * Build goto-line argv for a GUI editor. VS Code family uses -g file:line; 34: * subl uses bare file:line; others don't support goto-line. 35: */ 36: function guiGotoArgv( 37: guiFamily: string, 38: filePath: string, 39: line: number | undefined, 40: ): string[] { 41: if (!line) return [filePath] 42: if (VSCODE_FAMILY.has(guiFamily)) return ['-g', `${filePath}:${line}`] 43: if (guiFamily === 'subl') return [`${filePath}:${line}`] 44: return [filePath] 45: } 46: export function openFileInExternalEditor( 47: filePath: string, 48: line?: number, 49: ): boolean { 50: const editor = getExternalEditor() 51: if (!editor) return false 52: const parts = editor.split(' ') 53: const base = parts[0] ?? editor 54: const editorArgs = parts.slice(1) 55: const guiFamily = classifyGuiEditor(editor) 56: if (guiFamily) { 57: const gotoArgv = guiGotoArgv(guiFamily, filePath, line) 58: const detachedOpts: SpawnOptions = { detached: true, stdio: 'ignore' } 59: let child 60: if (process.platform === 'win32') { 61: const gotoStr = gotoArgv.map(a => `"${a}"`).join(' ') 62: child = spawn(`${editor} ${gotoStr}`, { ...detachedOpts, shell: true }) 63: } else { 64: child = spawn(base, [...editorArgs, ...gotoArgv], detachedOpts) 65: } 66: child.on('error', e => 67: logForDebugging(`editor spawn failed: ${e}`, { level: 'error' }), 68: ) 69: child.unref() 70: return true 71: } 72: const inkInstance = instances.get(process.stdout) 73: if (!inkInstance) return false 74: const useGotoLine = line && PLUS_N_EDITORS.test(basename(base)) 75: inkInstance.enterAlternateScreen() 76: try { 77: const syncOpts: SpawnSyncOptions = { stdio: 'inherit' } 78: let result 79: if (process.platform === 'win32') { 80: const lineArg = useGotoLine ? `+${line} ` : '' 81: result = spawnSync(`${editor} ${lineArg}"${filePath}"`, { 82: ...syncOpts, 83: shell: true, 84: }) 85: } else { 86: // POSIX: spawn directly (no shell), argv array is quote-safe. 87: const args = [ 88: ...editorArgs, 89: ...(useGotoLine ? [`+${line}`, filePath] : [filePath]), 90: ] 91: result = spawnSync(base, args, syncOpts) 92: } 93: if (result.error) { 94: logForDebugging(`editor spawn failed: ${result.error}`, { 95: level: 'error', 96: }) 97: return false 98: } 99: return true 100: } finally { 101: inkInstance.exitAlternateScreen() 102: } 103: } 104: export const getExternalEditor = memoize((): string | undefined => { 105: if (process.env.VISUAL?.trim()) { 106: return process.env.VISUAL.trim() 107: } 108: if (process.env.EDITOR?.trim()) { 109: return process.env.EDITOR.trim() 110: } 111: if (process.platform === 'win32') { 112: return 'start /wait notepad' 113: } 114: const editors = ['code', 'vi', 'nano'] 115: return editors.find(command => isCommandAvailable(command)) 116: })

File: src/utils/effort.ts

typescript 1: import { isUltrathinkEnabled } from './thinking.js' 2: import { getInitialSettings } from './settings/settings.js' 3: import { isProSubscriber, isMaxSubscriber, isTeamSubscriber } from './auth.js' 4: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 5: import { getAPIProvider } from './model/providers.js' 6: import { get3PModelCapabilityOverride } from './model/modelSupportOverrides.js' 7: import { isEnvTruthy } from './envUtils.js' 8: import type { EffortLevel } from 'src/entrypoints/sdk/runtimeTypes.js' 9: export type { EffortLevel } 10: export const EFFORT_LEVELS = [ 11: 'low', 12: 'medium', 13: 'high', 14: 'max', 15: ] as const satisfies readonly EffortLevel[] 16: export type EffortValue = EffortLevel | number 17: export function modelSupportsEffort(model: string): boolean { 18: const m = model.toLowerCase() 19: if (isEnvTruthy(process.env.CLAUDE_CODE_ALWAYS_ENABLE_EFFORT)) { 20: return true 21: } 22: const supported3P = get3PModelCapabilityOverride(model, 'effort') 23: if (supported3P !== undefined) { 24: return supported3P 25: } 26: if (m.includes('opus-4-6') || m.includes('sonnet-4-6')) { 27: return true 28: } 29: if (m.includes('haiku') || m.includes('sonnet') || m.includes('opus')) { 30: return false 31: } 32: return getAPIProvider() === 'firstParty' 33: } 34: export function modelSupportsMaxEffort(model: string): boolean { 35: const supported3P = get3PModelCapabilityOverride(model, 'max_effort') 36: if (supported3P !== undefined) { 37: return supported3P 38: } 39: if (model.toLowerCase().includes('opus-4-6')) { 40: return true 41: } 42: if (process.env.USER_TYPE === 'ant' && resolveAntModel(model)) { 43: return true 44: } 45: return false 46: } 47: export function isEffortLevel(value: string): value is EffortLevel { 48: return (EFFORT_LEVELS as readonly string[]).includes(value) 49: } 50: export function parseEffortValue(value: unknown): EffortValue | undefined { 51: if (value === undefined || value === null || value === '') { 52: return undefined 53: } 54: if (typeof value === 'number' && isValidNumericEffort(value)) { 55: return value 56: } 57: const str = String(value).toLowerCase() 58: if (isEffortLevel(str)) { 59: return str 60: } 61: const numericValue = parseInt(str, 10) 62: if (!isNaN(numericValue) && isValidNumericEffort(numericValue)) { 63: return numericValue 64: } 65: return undefined 66: } 67: export function toPersistableEffort( 68: value: EffortValue | undefined, 69: ): EffortLevel | undefined { 70: if (value === 'low' || value === 'medium' || value === 'high') { 71: return value 72: } 73: if (value === 'max' && process.env.USER_TYPE === 'ant') { 74: return value 75: } 76: return undefined 77: } 78: export function getInitialEffortSetting(): EffortLevel | undefined { 79: return toPersistableEffort(getInitialSettings().effortLevel) 80: } 81: export function resolvePickerEffortPersistence( 82: picked: EffortLevel | undefined, 83: modelDefault: EffortLevel, 84: priorPersisted: EffortLevel | undefined, 85: toggledInPicker: boolean, 86: ): EffortLevel | undefined { 87: const hadExplicit = priorPersisted !== undefined || toggledInPicker 88: return hadExplicit || picked !== modelDefault ? picked : undefined 89: } 90: export function getEffortEnvOverride(): EffortValue | null | undefined { 91: const envOverride = process.env.CLAUDE_CODE_EFFORT_LEVEL 92: return envOverride?.toLowerCase() === 'unset' || 93: envOverride?.toLowerCase() === 'auto' 94: ? null 95: : parseEffortValue(envOverride) 96: } 97: export function resolveAppliedEffort( 98: model: string, 99: appStateEffortValue: EffortValue | undefined, 100: ): EffortValue | undefined { 101: const envOverride = getEffortEnvOverride() 102: if (envOverride === null) { 103: return undefined 104: } 105: const resolved = 106: envOverride ?? appStateEffortValue ?? getDefaultEffortForModel(model) 107: if (resolved === 'max' && !modelSupportsMaxEffort(model)) { 108: return 'high' 109: } 110: return resolved 111: } 112: export function getDisplayedEffortLevel( 113: model: string, 114: appStateEffort: EffortValue | undefined, 115: ): EffortLevel { 116: const resolved = resolveAppliedEffort(model, appStateEffort) ?? 'high' 117: return convertEffortValueToLevel(resolved) 118: } 119: export function getEffortSuffix( 120: model: string, 121: effortValue: EffortValue | undefined, 122: ): string { 123: if (effortValue === undefined) return '' 124: const resolved = resolveAppliedEffort(model, effortValue) 125: if (resolved === undefined) return '' 126: return ` with ${convertEffortValueToLevel(resolved)} effort` 127: } 128: export function isValidNumericEffort(value: number): boolean { 129: return Number.isInteger(value) 130: } 131: export function convertEffortValueToLevel(value: EffortValue): EffortLevel { 132: if (typeof value === 'string') { 133: return isEffortLevel(value) ? value : 'high' 134: } 135: if (process.env.USER_TYPE === 'ant' && typeof value === 'number') { 136: if (value <= 50) return 'low' 137: if (value <= 85) return 'medium' 138: if (value <= 100) return 'high' 139: return 'max' 140: } 141: return 'high' 142: } 143: export function getEffortLevelDescription(level: EffortLevel): string { 144: switch (level) { 145: case 'low': 146: return 'Quick, straightforward implementation with minimal overhead' 147: case 'medium': 148: return 'Balanced approach with standard implementation and testing' 149: case 'high': 150: return 'Comprehensive implementation with extensive testing and documentation' 151: case 'max': 152: return 'Maximum capability with deepest reasoning (Opus 4.6 only)' 153: } 154: } 155: export function getEffortValueDescription(value: EffortValue): string { 156: if (process.env.USER_TYPE === 'ant' && typeof value === 'number') { 157: return `[ANT-ONLY] Numeric effort value of ${value}` 158: } 159: if (typeof value === 'string') { 160: return getEffortLevelDescription(value) 161: } 162: return 'Balanced approach with standard implementation and testing' 163: } 164: export type OpusDefaultEffortConfig = { 165: enabled: boolean 166: dialogTitle: string 167: dialogDescription: string 168: } 169: const OPUS_DEFAULT_EFFORT_CONFIG_DEFAULT: OpusDefaultEffortConfig = { 170: enabled: true, 171: dialogTitle: 'We recommend medium effort for Opus', 172: dialogDescription: 173: 'Effort determines how long Claude thinks for when completing your task. We recommend medium effort for most tasks to balance speed and intelligence and maximize rate limits. Use ultrathink to trigger high effort when needed.', 174: } 175: export function getOpusDefaultEffortConfig(): OpusDefaultEffortConfig { 176: const config = getFeatureValue_CACHED_MAY_BE_STALE( 177: 'tengu_grey_step2', 178: OPUS_DEFAULT_EFFORT_CONFIG_DEFAULT, 179: ) 180: return { 181: ...OPUS_DEFAULT_EFFORT_CONFIG_DEFAULT, 182: ...config, 183: } 184: } 185: export function getDefaultEffortForModel( 186: model: string, 187: ): EffortValue | undefined { 188: if (process.env.USER_TYPE === 'ant') { 189: const config = getAntModelOverrideConfig() 190: const isDefaultModel = 191: config?.defaultModel !== undefined && 192: model.toLowerCase() === config.defaultModel.toLowerCase() 193: if (isDefaultModel && config?.defaultModelEffortLevel) { 194: return config.defaultModelEffortLevel 195: } 196: const antModel = resolveAntModel(model) 197: if (antModel) { 198: if (antModel.defaultEffortLevel) { 199: return antModel.defaultEffortLevel 200: } 201: if (antModel.defaultEffortValue !== undefined) { 202: return antModel.defaultEffortValue 203: } 204: } 205: return undefined 206: } 207: if (model.toLowerCase().includes('opus-4-6')) { 208: if (isProSubscriber()) { 209: return 'medium' 210: } 211: if ( 212: getOpusDefaultEffortConfig().enabled && 213: (isMaxSubscriber() || isTeamSubscriber()) 214: ) { 215: return 'medium' 216: } 217: } 218: if (isUltrathinkEnabled() && modelSupportsEffort(model)) { 219: return 'medium' 220: } 221: return undefined 222: }

File: src/utils/embeddedTools.ts

typescript 1: import { isEnvTruthy } from './envUtils.js' 2: export function hasEmbeddedSearchTools(): boolean { 3: if (!isEnvTruthy(process.env.EMBEDDED_SEARCH_TOOLS)) return false 4: const e = process.env.CLAUDE_CODE_ENTRYPOINT 5: return ( 6: e !== 'sdk-ts' && e !== 'sdk-py' && e !== 'sdk-cli' && e !== 'local-agent' 7: ) 8: } 9: export function embeddedSearchToolsBinaryPath(): string { 10: return process.execPath 11: }

File: src/utils/env.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import { homedir } from 'os' 3: import { join } from 'path' 4: import { fileSuffixForOauthConfig } from '../constants/oauth.js' 5: import { isRunningWithBun } from './bundledMode.js' 6: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 7: import { findExecutable } from './findExecutable.js' 8: import { getFsImplementation } from './fsOperations.js' 9: import { which } from './which.js' 10: type Platform = 'win32' | 'darwin' | 'linux' 11: export const getGlobalClaudeFile = memoize((): string => { 12: if ( 13: getFsImplementation().existsSync( 14: join(getClaudeConfigHomeDir(), '.config.json'), 15: ) 16: ) { 17: return join(getClaudeConfigHomeDir(), '.config.json') 18: } 19: const filename = `.claude${fileSuffixForOauthConfig()}.json` 20: return join(process.env.CLAUDE_CONFIG_DIR || homedir(), filename) 21: }) 22: const hasInternetAccess = memoize(async (): Promise<boolean> => { 23: try { 24: const { default: axiosClient } = await import('axios') 25: await axiosClient.head('http://1.1.1.1', { 26: signal: AbortSignal.timeout(1000), 27: }) 28: return true 29: } catch { 30: return false 31: } 32: }) 33: async function isCommandAvailable(command: string): Promise<boolean> { 34: try { 35: return !!(await which(command)) 36: } catch { 37: return false 38: } 39: } 40: const detectPackageManagers = memoize(async (): Promise<string[]> => { 41: const packageManagers = [] 42: if (await isCommandAvailable('npm')) packageManagers.push('npm') 43: if (await isCommandAvailable('yarn')) packageManagers.push('yarn') 44: if (await isCommandAvailable('pnpm')) packageManagers.push('pnpm') 45: return packageManagers 46: }) 47: const detectRuntimes = memoize(async (): Promise<string[]> => { 48: const runtimes = [] 49: if (await isCommandAvailable('bun')) runtimes.push('bun') 50: if (await isCommandAvailable('deno')) runtimes.push('deno') 51: if (await isCommandAvailable('node')) runtimes.push('node') 52: return runtimes 53: }) 54: const isWslEnvironment = memoize((): boolean => { 55: try { 56: return getFsImplementation().existsSync( 57: '/proc/sys/fs/binfmt_misc/WSLInterop', 58: ) 59: } catch (_error) { 60: return false 61: } 62: }) 63: const isNpmFromWindowsPath = memoize((): boolean => { 64: try { 65: if (!isWslEnvironment()) { 66: return false 67: } 68: const { cmd } = findExecutable('npm', []) 69: return cmd.startsWith('/mnt/c/') 70: } catch (_error) { 71: return false 72: } 73: }) 74: function isConductor(): boolean { 75: return process.env.__CFBundleIdentifier === 'com.conductor.app' 76: } 77: export const JETBRAINS_IDES = [ 78: 'pycharm', 79: 'intellij', 80: 'webstorm', 81: 'phpstorm', 82: 'rubymine', 83: 'clion', 84: 'goland', 85: 'rider', 86: 'datagrip', 87: 'appcode', 88: 'dataspell', 89: 'aqua', 90: 'gateway', 91: 'fleet', 92: 'jetbrains', 93: 'androidstudio', 94: ] 95: function detectTerminal(): string | null { 96: if (process.env.CURSOR_TRACE_ID) return 'cursor' 97: if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('cursor')) { 98: return 'cursor' 99: } 100: if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('windsurf')) { 101: return 'windsurf' 102: } 103: if (process.env.VSCODE_GIT_ASKPASS_MAIN?.includes('antigravity')) { 104: return 'antigravity' 105: } 106: const bundleId = process.env.__CFBundleIdentifier?.toLowerCase() 107: if (bundleId?.includes('vscodium')) return 'codium' 108: if (bundleId?.includes('windsurf')) return 'windsurf' 109: if (bundleId?.includes('com.google.android.studio')) return 'androidstudio' 110: if (bundleId) { 111: for (const ide of JETBRAINS_IDES) { 112: if (bundleId.includes(ide)) return ide 113: } 114: } 115: if (process.env.VisualStudioVersion) { 116: return 'visualstudio' 117: } 118: if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') { 119: if (process.platform === 'darwin') return 'pycharm' 120: return 'pycharm' 121: } 122: if (process.env.TERM === 'xterm-ghostty') { 123: return 'ghostty' 124: } 125: if (process.env.TERM?.includes('kitty')) { 126: return 'kitty' 127: } 128: if (process.env.TERM_PROGRAM) { 129: return process.env.TERM_PROGRAM 130: } 131: if (process.env.TMUX) return 'tmux' 132: if (process.env.STY) return 'screen' 133: if (process.env.KONSOLE_VERSION) return 'konsole' 134: if (process.env.GNOME_TERMINAL_SERVICE) return 'gnome-terminal' 135: if (process.env.XTERM_VERSION) return 'xterm' 136: if (process.env.VTE_VERSION) return 'vte-based' 137: if (process.env.TERMINATOR_UUID) return 'terminator' 138: if (process.env.KITTY_WINDOW_ID) { 139: return 'kitty' 140: } 141: if (process.env.ALACRITTY_LOG) return 'alacritty' 142: if (process.env.TILIX_ID) return 'tilix' 143: if (process.env.WT_SESSION) return 'windows-terminal' 144: if (process.env.SESSIONNAME && process.env.TERM === 'cygwin') return 'cygwin' 145: if (process.env.MSYSTEM) return process.env.MSYSTEM.toLowerCase() 146: if ( 147: process.env.ConEmuANSI || 148: process.env.ConEmuPID || 149: process.env.ConEmuTask 150: ) { 151: return 'conemu' 152: } 153: if (process.env.WSL_DISTRO_NAME) return `wsl-${process.env.WSL_DISTRO_NAME}` 154: if (isSSHSession()) { 155: return 'ssh-session' 156: } 157: if (process.env.TERM) { 158: const term = process.env.TERM 159: if (term.includes('alacritty')) return 'alacritty' 160: if (term.includes('rxvt')) return 'rxvt' 161: if (term.includes('termite')) return 'termite' 162: return process.env.TERM 163: } 164: if (!process.stdout.isTTY) return 'non-interactive' 165: return null 166: } 167: export const detectDeploymentEnvironment = memoize((): string => { 168: if (isEnvTruthy(process.env.CODESPACES)) return 'codespaces' 169: if (process.env.GITPOD_WORKSPACE_ID) return 'gitpod' 170: if (process.env.REPL_ID || process.env.REPL_SLUG) return 'replit' 171: if (process.env.PROJECT_DOMAIN) return 'glitch' 172: if (isEnvTruthy(process.env.VERCEL)) return 'vercel' 173: if ( 174: process.env.RAILWAY_ENVIRONMENT_NAME || 175: process.env.RAILWAY_SERVICE_NAME 176: ) { 177: return 'railway' 178: } 179: if (isEnvTruthy(process.env.RENDER)) return 'render' 180: if (isEnvTruthy(process.env.NETLIFY)) return 'netlify' 181: if (process.env.DYNO) return 'heroku' 182: if (process.env.FLY_APP_NAME || process.env.FLY_MACHINE_ID) return 'fly.io' 183: if (isEnvTruthy(process.env.CF_PAGES)) return 'cloudflare-pages' 184: if (process.env.DENO_DEPLOYMENT_ID) return 'deno-deploy' 185: if (process.env.AWS_LAMBDA_FUNCTION_NAME) return 'aws-lambda' 186: if (process.env.AWS_EXECUTION_ENV === 'AWS_ECS_FARGATE') return 'aws-fargate' 187: if (process.env.AWS_EXECUTION_ENV === 'AWS_ECS_EC2') return 'aws-ecs' 188: try { 189: const uuid = getFsImplementation() 190: .readFileSync('/sys/hypervisor/uuid', { encoding: 'utf8' }) 191: .trim() 192: .toLowerCase() 193: if (uuid.startsWith('ec2')) return 'aws-ec2' 194: } catch { 195: } 196: if (process.env.K_SERVICE) return 'gcp-cloud-run' 197: if (process.env.GOOGLE_CLOUD_PROJECT) return 'gcp' 198: if (process.env.WEBSITE_SITE_NAME || process.env.WEBSITE_SKU) 199: return 'azure-app-service' 200: if (process.env.AZURE_FUNCTIONS_ENVIRONMENT) return 'azure-functions' 201: if (process.env.APP_URL?.includes('ondigitalocean.app')) { 202: return 'digitalocean-app-platform' 203: } 204: if (process.env.SPACE_CREATOR_USER_ID) return 'huggingface-spaces' 205: if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-actions' 206: if (isEnvTruthy(process.env.GITLAB_CI)) return 'gitlab-ci' 207: if (process.env.CIRCLECI) return 'circleci' 208: if (process.env.BUILDKITE) return 'buildkite' 209: if (isEnvTruthy(process.env.CI)) return 'ci' 210: if (process.env.KUBERNETES_SERVICE_HOST) return 'kubernetes' 211: try { 212: if (getFsImplementation().existsSync('/.dockerenv')) return 'docker' 213: } catch { 214: } 215: if (env.platform === 'darwin') return 'unknown-darwin' 216: if (env.platform === 'linux') return 'unknown-linux' 217: if (env.platform === 'win32') return 'unknown-win32' 218: return 'unknown' 219: }) 220: function isSSHSession(): boolean { 221: return !!( 222: process.env.SSH_CONNECTION || 223: process.env.SSH_CLIENT || 224: process.env.SSH_TTY 225: ) 226: } 227: export const env = { 228: hasInternetAccess, 229: isCI: isEnvTruthy(process.env.CI), 230: platform: (['win32', 'darwin'].includes(process.platform) 231: ? process.platform 232: : 'linux') as Platform, 233: arch: process.arch, 234: nodeVersion: process.version, 235: terminal: detectTerminal(), 236: isSSH: isSSHSession, 237: getPackageManagers: detectPackageManagers, 238: getRuntimes: detectRuntimes, 239: isRunningWithBun: memoize(isRunningWithBun), 240: isWslEnvironment, 241: isNpmFromWindowsPath, 242: isConductor, 243: detectDeploymentEnvironment, 244: } 245: export function getHostPlatformForAnalytics(): Platform { 246: const override = process.env.CLAUDE_CODE_HOST_PLATFORM 247: if (override === 'win32' || override === 'darwin' || override === 'linux') { 248: return override 249: } 250: return env.platform 251: }

File: src/utils/envDynamic.ts

typescript 1: import { feature } from 'bun:bundle' 2: import { stat } from 'fs/promises' 3: import memoize from 'lodash-es/memoize.js' 4: import { env, JETBRAINS_IDES } from './env.js' 5: import { isEnvTruthy } from './envUtils.js' 6: import { execFileNoThrow } from './execFileNoThrow.js' 7: import { getAncestorCommandsAsync } from './genericProcessUtils.js' 8: const getIsDocker = memoize(async (): Promise<boolean> => { 9: if (process.platform !== 'linux') return false 10: const { code } = await execFileNoThrow('test', ['-f', '/.dockerenv']) 11: return code === 0 12: }) 13: function getIsBubblewrapSandbox(): boolean { 14: return ( 15: process.platform === 'linux' && 16: isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) 17: ) 18: } 19: let muslRuntimeCache: boolean | null = null 20: if (process.platform === 'linux') { 21: const muslArch = process.arch === 'x64' ? 'x86_64' : 'aarch64' 22: void stat(`/lib/libc.musl-${muslArch}.so.1`).then( 23: () => { 24: muslRuntimeCache = true 25: }, 26: () => { 27: muslRuntimeCache = false 28: }, 29: ) 30: } 31: function isMuslEnvironment(): boolean { 32: if (feature('IS_LIBC_MUSL')) return true 33: if (feature('IS_LIBC_GLIBC')) return false 34: if (process.platform !== 'linux') return false 35: return muslRuntimeCache ?? false 36: } 37: let jetBrainsIDECache: string | null | undefined 38: async function detectJetBrainsIDEFromParentProcessAsync(): Promise< 39: string | null 40: > { 41: if (jetBrainsIDECache !== undefined) { 42: return jetBrainsIDECache 43: } 44: if (process.platform === 'darwin') { 45: jetBrainsIDECache = null 46: return null 47: } 48: try { 49: const commands = await getAncestorCommandsAsync(process.pid, 10) 50: for (const command of commands) { 51: const lowerCommand = command.toLowerCase() 52: for (const ide of JETBRAINS_IDES) { 53: if (lowerCommand.includes(ide)) { 54: jetBrainsIDECache = ide 55: return ide 56: } 57: } 58: } 59: } catch { 60: } 61: jetBrainsIDECache = null 62: return null 63: } 64: export async function getTerminalWithJetBrainsDetectionAsync(): Promise< 65: string | null 66: > { 67: if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') { 68: if (env.platform !== 'darwin') { 69: const specificIDE = await detectJetBrainsIDEFromParentProcessAsync() 70: return specificIDE || 'pycharm' 71: } 72: } 73: return env.terminal 74: } 75: export function getTerminalWithJetBrainsDetection(): string | null { 76: if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') { 77: if (env.platform !== 'darwin') { 78: if (jetBrainsIDECache !== undefined) { 79: return jetBrainsIDECache || 'pycharm' 80: } 81: return 'pycharm' 82: } 83: } 84: return env.terminal 85: } 86: export async function initJetBrainsDetection(): Promise<void> { 87: if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') { 88: await detectJetBrainsIDEFromParentProcessAsync() 89: } 90: } 91: export const envDynamic = { 92: ...env, 93: terminal: getTerminalWithJetBrainsDetection(), 94: getIsDocker, 95: getIsBubblewrapSandbox, 96: isMuslEnvironment, 97: getTerminalWithJetBrainsDetectionAsync, 98: initJetBrainsDetection, 99: }

File: src/utils/envUtils.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import { homedir } from 'os' 3: import { join } from 'path' 4: export const getClaudeConfigHomeDir = memoize( 5: (): string => { 6: return ( 7: process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), '.claude') 8: ).normalize('NFC') 9: }, 10: () => process.env.CLAUDE_CONFIG_DIR, 11: ) 12: export function getTeamsDir(): string { 13: return join(getClaudeConfigHomeDir(), 'teams') 14: } 15: export function hasNodeOption(flag: string): boolean { 16: const nodeOptions = process.env.NODE_OPTIONS 17: if (!nodeOptions) { 18: return false 19: } 20: return nodeOptions.split(/\s+/).includes(flag) 21: } 22: export function isEnvTruthy(envVar: string | boolean | undefined): boolean { 23: if (!envVar) return false 24: if (typeof envVar === 'boolean') return envVar 25: const normalizedValue = envVar.toLowerCase().trim() 26: return ['1', 'true', 'yes', 'on'].includes(normalizedValue) 27: } 28: export function isEnvDefinedFalsy( 29: envVar: string | boolean | undefined, 30: ): boolean { 31: if (envVar === undefined) return false 32: if (typeof envVar === 'boolean') return !envVar 33: if (!envVar) return false 34: const normalizedValue = envVar.toLowerCase().trim() 35: return ['0', 'false', 'no', 'off'].includes(normalizedValue) 36: } 37: export function isBareMode(): boolean { 38: return ( 39: isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE) || 40: process.argv.includes('--bare') 41: ) 42: } 43: export function parseEnvVars( 44: rawEnvArgs: string[] | undefined, 45: ): Record<string, string> { 46: const parsedEnv: Record<string, string> = {} 47: if (rawEnvArgs) { 48: for (const envStr of rawEnvArgs) { 49: const [key, ...valueParts] = envStr.split('=') 50: if (!key || valueParts.length === 0) { 51: throw new Error( 52: `Invalid environment variable format: ${envStr}, environment variables should be added as: -e KEY1=value1 -e KEY2=value2`, 53: ) 54: } 55: parsedEnv[key] = valueParts.join('=') 56: } 57: } 58: return parsedEnv 59: } 60: export function getAWSRegion(): string { 61: return process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || 'us-east-1' 62: } 63: export function getDefaultVertexRegion(): string { 64: return process.env.CLOUD_ML_REGION || 'us-east5' 65: } 66: export function shouldMaintainProjectWorkingDir(): boolean { 67: return isEnvTruthy(process.env.CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR) 68: } 69: export function isRunningOnHomespace(): boolean { 70: return ( 71: process.env.USER_TYPE === 'ant' && 72: isEnvTruthy(process.env.COO_RUNNING_ON_HOMESPACE) 73: ) 74: } 75: export function isInProtectedNamespace(): boolean { 76: if (process.env.USER_TYPE === 'ant') { 77: return ( 78: require('./protectedNamespace.js') as typeof import('./protectedNamespace.js') 79: ).checkProtectedNamespace() 80: } 81: return false 82: } 83: const VERTEX_REGION_OVERRIDES: ReadonlyArray<[string, string]> = [ 84: ['claude-haiku-4-5', 'VERTEX_REGION_CLAUDE_HAIKU_4_5'], 85: ['claude-3-5-haiku', 'VERTEX_REGION_CLAUDE_3_5_HAIKU'], 86: ['claude-3-5-sonnet', 'VERTEX_REGION_CLAUDE_3_5_SONNET'], 87: ['claude-3-7-sonnet', 'VERTEX_REGION_CLAUDE_3_7_SONNET'], 88: ['claude-opus-4-1', 'VERTEX_REGION_CLAUDE_4_1_OPUS'], 89: ['claude-opus-4', 'VERTEX_REGION_CLAUDE_4_0_OPUS'], 90: ['claude-sonnet-4-6', 'VERTEX_REGION_CLAUDE_4_6_SONNET'], 91: ['claude-sonnet-4-5', 'VERTEX_REGION_CLAUDE_4_5_SONNET'], 92: ['claude-sonnet-4', 'VERTEX_REGION_CLAUDE_4_0_SONNET'], 93: ] 94: export function getVertexRegionForModel( 95: model: string | undefined, 96: ): string | undefined { 97: if (model) { 98: const match = VERTEX_REGION_OVERRIDES.find(([prefix]) => 99: model.startsWith(prefix), 100: ) 101: if (match) { 102: return process.env[match[1]] || getDefaultVertexRegion() 103: } 104: } 105: return getDefaultVertexRegion() 106: }

File: src/utils/envValidation.ts

typescript 1: import { logForDebugging } from './debug.js' 2: export type EnvVarValidationResult = { 3: effective: number 4: status: 'valid' | 'capped' | 'invalid' 5: message?: string 6: } 7: export function validateBoundedIntEnvVar( 8: name: string, 9: value: string | undefined, 10: defaultValue: number, 11: upperLimit: number, 12: ): EnvVarValidationResult { 13: if (!value) { 14: return { effective: defaultValue, status: 'valid' } 15: } 16: const parsed = parseInt(value, 10) 17: if (isNaN(parsed) || parsed <= 0) { 18: const result: EnvVarValidationResult = { 19: effective: defaultValue, 20: status: 'invalid', 21: message: `Invalid value "${value}" (using default: ${defaultValue})`, 22: } 23: logForDebugging(`${name} ${result.message}`) 24: return result 25: } 26: if (parsed > upperLimit) { 27: const result: EnvVarValidationResult = { 28: effective: upperLimit, 29: status: 'capped', 30: message: `Capped from ${parsed} to ${upperLimit}`, 31: } 32: logForDebugging(`${name} ${result.message}`) 33: return result 34: } 35: return { effective: parsed, status: 'valid' } 36: }

File: src/utils/errorLogSink.ts

typescript 1: import axios from 'axios' 2: import { dirname, join } from 'path' 3: import { getSessionId } from '../bootstrap/state.js' 4: import { createBufferedWriter } from './bufferedWriter.js' 5: import { CACHE_PATHS } from './cachePaths.js' 6: import { registerCleanup } from './cleanupRegistry.js' 7: import { logForDebugging } from './debug.js' 8: import { getFsImplementation } from './fsOperations.js' 9: import { attachErrorLogSink, dateToFilename } from './log.js' 10: import { jsonStringify } from './slowOperations.js' 11: const DATE = dateToFilename(new Date()) 12: export function getErrorsPath(): string { 13: return join(CACHE_PATHS.errors(), DATE + '.jsonl') 14: } 15: export function getMCPLogsPath(serverName: string): string { 16: return join(CACHE_PATHS.mcpLogs(serverName), DATE + '.jsonl') 17: } 18: type JsonlWriter = { 19: write: (obj: object) => void 20: flush: () => void 21: dispose: () => void 22: } 23: function createJsonlWriter(options: { 24: writeFn: (content: string) => void 25: flushIntervalMs?: number 26: maxBufferSize?: number 27: }): JsonlWriter { 28: const writer = createBufferedWriter(options) 29: return { 30: write(obj: object): void { 31: writer.write(jsonStringify(obj) + '\n') 32: }, 33: flush: writer.flush, 34: dispose: writer.dispose, 35: } 36: } 37: const logWriters = new Map<string, JsonlWriter>() 38: export function _flushLogWritersForTesting(): void { 39: for (const writer of logWriters.values()) { 40: writer.flush() 41: } 42: } 43: export function _clearLogWritersForTesting(): void { 44: for (const writer of logWriters.values()) { 45: writer.dispose() 46: } 47: logWriters.clear() 48: } 49: function getLogWriter(path: string): JsonlWriter { 50: let writer = logWriters.get(path) 51: if (!writer) { 52: const dir = dirname(path) 53: writer = createJsonlWriter({ 54: writeFn: (content: string) => { 55: try { 56: getFsImplementation().appendFileSync(path, content) 57: } catch { 58: getFsImplementation().mkdirSync(dir) 59: getFsImplementation().appendFileSync(path, content) 60: } 61: }, 62: flushIntervalMs: 1000, 63: maxBufferSize: 50, 64: }) 65: logWriters.set(path, writer) 66: registerCleanup(async () => writer?.dispose()) 67: } 68: return writer 69: } 70: function appendToLog(path: string, message: object): void { 71: if (process.env.USER_TYPE !== 'ant') { 72: return 73: } 74: const messageWithTimestamp = { 75: timestamp: new Date().toISOString(), 76: ...message, 77: cwd: getFsImplementation().cwd(), 78: userType: process.env.USER_TYPE, 79: sessionId: getSessionId(), 80: version: MACRO.VERSION, 81: } 82: getLogWriter(path).write(messageWithTimestamp) 83: } 84: function extractServerMessage(data: unknown): string | undefined { 85: if (typeof data === 'string') { 86: return data 87: } 88: if (data && typeof data === 'object') { 89: const obj = data as Record<string, unknown> 90: if (typeof obj.message === 'string') { 91: return obj.message 92: } 93: if ( 94: typeof obj.error === 'object' && 95: obj.error && 96: 'message' in obj.error && 97: typeof (obj.error as Record<string, unknown>).message === 'string' 98: ) { 99: return (obj.error as Record<string, unknown>).message as string 100: } 101: } 102: return undefined 103: } 104: function logErrorImpl(error: Error): void { 105: const errorStr = error.stack || error.message 106: let context = '' 107: if (axios.isAxiosError(error) && error.config?.url) { 108: const parts = [`url=${error.config.url}`] 109: if (error.response?.status !== undefined) { 110: parts.push(`status=${error.response.status}`) 111: } 112: const serverMessage = extractServerMessage(error.response?.data) 113: if (serverMessage) { 114: parts.push(`body=${serverMessage}`) 115: } 116: context = `[${parts.join(',')}] ` 117: } 118: logForDebugging(`${error.name}: ${context}${errorStr}`, { level: 'error' }) 119: appendToLog(getErrorsPath(), { 120: error: `${context}${errorStr}`, 121: }) 122: } 123: function logMCPErrorImpl(serverName: string, error: unknown): void { 124: logForDebugging(`MCP server "${serverName}" ${error}`, { level: 'error' }) 125: const logFile = getMCPLogsPath(serverName) 126: const errorStr = 127: error instanceof Error ? error.stack || error.message : String(error) 128: const errorInfo = { 129: error: errorStr, 130: timestamp: new Date().toISOString(), 131: sessionId: getSessionId(), 132: cwd: getFsImplementation().cwd(), 133: } 134: getLogWriter(logFile).write(errorInfo) 135: } 136: function logMCPDebugImpl(serverName: string, message: string): void { 137: logForDebugging(`MCP server "${serverName}": ${message}`) 138: const logFile = getMCPLogsPath(serverName) 139: const debugInfo = { 140: debug: message, 141: timestamp: new Date().toISOString(), 142: sessionId: getSessionId(), 143: cwd: getFsImplementation().cwd(), 144: } 145: getLogWriter(logFile).write(debugInfo) 146: } 147: export function initializeErrorLogSink(): void { 148: attachErrorLogSink({ 149: logError: logErrorImpl, 150: logMCPError: logMCPErrorImpl, 151: logMCPDebug: logMCPDebugImpl, 152: getErrorsPath, 153: getMCPLogsPath, 154: }) 155: logForDebugging('Error log sink initialized') 156: }

File: src/utils/errors.ts

typescript 1: import { APIUserAbortError } from '@anthropic-ai/sdk' 2: export class ClaudeError extends Error { 3: constructor(message: string) { 4: super(message) 5: this.name = this.constructor.name 6: } 7: } 8: export class MalformedCommandError extends Error {} 9: export class AbortError extends Error { 10: constructor(message?: string) { 11: super(message) 12: this.name = 'AbortError' 13: } 14: } 15: export function isAbortError(e: unknown): boolean { 16: return ( 17: e instanceof AbortError || 18: e instanceof APIUserAbortError || 19: (e instanceof Error && e.name === 'AbortError') 20: ) 21: } 22: export class ConfigParseError extends Error { 23: filePath: string 24: defaultConfig: unknown 25: constructor(message: string, filePath: string, defaultConfig: unknown) { 26: super(message) 27: this.name = 'ConfigParseError' 28: this.filePath = filePath 29: this.defaultConfig = defaultConfig 30: } 31: } 32: export class ShellError extends Error { 33: constructor( 34: public readonly stdout: string, 35: public readonly stderr: string, 36: public readonly code: number, 37: public readonly interrupted: boolean, 38: ) { 39: super('Shell command failed') 40: this.name = 'ShellError' 41: } 42: } 43: export class TeleportOperationError extends Error { 44: constructor( 45: message: string, 46: public readonly formattedMessage: string, 47: ) { 48: super(message) 49: this.name = 'TeleportOperationError' 50: } 51: } 52: export class TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS extends Error { 53: readonly telemetryMessage: string 54: constructor(message: string, telemetryMessage?: string) { 55: super(message) 56: this.name = 'TelemetrySafeError' 57: this.telemetryMessage = telemetryMessage ?? message 58: } 59: } 60: export function hasExactErrorMessage(error: unknown, message: string): boolean { 61: return error instanceof Error && error.message === message 62: } 63: export function toError(e: unknown): Error { 64: return e instanceof Error ? e : new Error(String(e)) 65: } 66: export function errorMessage(e: unknown): string { 67: return e instanceof Error ? e.message : String(e) 68: } 69: export function getErrnoCode(e: unknown): string | undefined { 70: if (e && typeof e === 'object' && 'code' in e && typeof e.code === 'string') { 71: return e.code 72: } 73: return undefined 74: } 75: export function isENOENT(e: unknown): boolean { 76: return getErrnoCode(e) === 'ENOENT' 77: } 78: export function getErrnoPath(e: unknown): string | undefined { 79: if (e && typeof e === 'object' && 'path' in e && typeof e.path === 'string') { 80: return e.path 81: } 82: return undefined 83: } 84: export function shortErrorStack(e: unknown, maxFrames = 5): string { 85: if (!(e instanceof Error)) return String(e) 86: if (!e.stack) return e.message 87: const lines = e.stack.split('\n') 88: const header = lines[0] ?? e.message 89: const frames = lines.slice(1).filter(l => l.trim().startsWith('at ')) 90: if (frames.length <= maxFrames) return e.stack 91: return [header, ...frames.slice(0, maxFrames)].join('\n') 92: } 93: export function isFsInaccessible(e: unknown): e is NodeJS.ErrnoException { 94: const code = getErrnoCode(e) 95: return ( 96: code === 'ENOENT' || 97: code === 'EACCES' || 98: code === 'EPERM' || 99: code === 'ENOTDIR' || 100: code === 'ELOOP' 101: ) 102: } 103: export type AxiosErrorKind = 104: | 'auth' 105: | 'timeout' 106: | 'network' 107: | 'http' 108: | 'other' 109: export function classifyAxiosError(e: unknown): { 110: kind: AxiosErrorKind 111: status?: number 112: message: string 113: } { 114: const message = errorMessage(e) 115: if ( 116: !e || 117: typeof e !== 'object' || 118: !('isAxiosError' in e) || 119: !e.isAxiosError 120: ) { 121: return { kind: 'other', message } 122: } 123: const err = e as { 124: response?: { status?: number } 125: code?: string 126: } 127: const status = err.response?.status 128: if (status === 401 || status === 403) return { kind: 'auth', status, message } 129: if (err.code === 'ECONNABORTED') return { kind: 'timeout', status, message } 130: if (err.code === 'ECONNREFUSED' || err.code === 'ENOTFOUND') { 131: return { kind: 'network', status, message } 132: } 133: return { kind: 'http', status, message } 134: }

File: src/utils/exampleCommands.ts

typescript 1: import memoize from 'lodash-es/memoize.js' 2: import sample from 'lodash-es/sample.js' 3: import { getCwd } from '../utils/cwd.js' 4: import { getCurrentProjectConfig, saveCurrentProjectConfig } from './config.js' 5: import { env } from './env.js' 6: import { execFileNoThrowWithCwd } from './execFileNoThrow.js' 7: import { getIsGit, gitExe } from './git.js' 8: import { logError } from './log.js' 9: import { getGitEmail } from './user.js' 10: const NON_CORE_PATTERNS = [ 11: /(?:^|\/)(?:package-lock\.json|yarn\.lock|bun\.lock|bun\.lockb|pnpm-lock\.yaml|Pipfile\.lock|poetry\.lock|Cargo\.lock|Gemfile\.lock|go\.sum|composer\.lock|uv\.lock)$/, 12: /\.generated\./, 13: /(?:^|\/)(?:dist|build|out|target|node_modules|\.next|__pycache__)\//, 14: /\.(?:min\.js|min\.css|map|pyc|pyo)$/, 15: /\.(?:json|ya?ml|toml|xml|ini|cfg|conf|env|lock|txt|md|mdx|rst|csv|log|svg)$/i, 16: /(?:^|\/)\.?(?:eslintrc|prettierrc|babelrc|editorconfig|gitignore|gitattributes|dockerignore|npmrc)/, 17: /(?:^|\/)(?:tsconfig|jsconfig|biome|vitest\.config|jest\.config|webpack\.config|vite\.config|rollup\.config)\.[a-z]+$/, 18: /(?:^|\/)\.(?:github|vscode|idea|claude)\//, 19: /(?:^|\/)(?:CHANGELOG|LICENSE|CONTRIBUTING|CODEOWNERS|README)(?:\.[a-z]+)?$/i, 20: ] 21: function isCoreFile(path: string): boolean { 22: return !NON_CORE_PATTERNS.some(p => p.test(path)) 23: } 24: export function countAndSortItems(items: string[], topN: number = 20): string { 25: const counts = new Map<string, number>() 26: for (const item of items) { 27: counts.set(item, (counts.get(item) || 0) + 1) 28: } 29: return Array.from(counts.entries()) 30: .sort((a, b) => b[1] - a[1]) 31: .slice(0, topN) 32: .map(([item, count]) => `${count.toString().padStart(6)} ${item}`) 33: .join('\n') 34: } 35: export function pickDiverseCoreFiles( 36: sortedPaths: string[], 37: want: number, 38: ): string[] { 39: const picked: string[] = [] 40: const seenBasenames = new Set<string>() 41: const dirTally = new Map<string, number>() 42: for (let cap = 1; picked.length < want && cap <= want; cap++) { 43: for (const p of sortedPaths) { 44: if (picked.length >= want) break 45: if (!isCoreFile(p)) continue 46: const lastSep = Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\')) 47: const base = lastSep >= 0 ? p.slice(lastSep + 1) : p 48: if (!base || seenBasenames.has(base)) continue 49: const dir = lastSep >= 0 ? p.slice(0, lastSep) : '.' 50: if ((dirTally.get(dir) ?? 0) >= cap) continue 51: picked.push(base) 52: seenBasenames.add(base) 53: dirTally.set(dir, (dirTally.get(dir) ?? 0) + 1) 54: } 55: } 56: return picked.length >= want ? picked : [] 57: } 58: async function getFrequentlyModifiedFiles(): Promise<string[]> { 59: if (process.env.NODE_ENV === 'test') return [] 60: if (env.platform === 'win32') return [] 61: if (!(await getIsGit())) return [] 62: try { 63: const userEmail = await getGitEmail() 64: const logArgs = [ 65: 'log', 66: '-n', 67: '1000', 68: '--pretty=format:', 69: '--name-only', 70: '--diff-filter=M', 71: ] 72: const counts = new Map<string, number>() 73: const tallyInto = (stdout: string) => { 74: for (const line of stdout.split('\n')) { 75: const f = line.trim() 76: if (f) counts.set(f, (counts.get(f) ?? 0) + 1) 77: } 78: } 79: if (userEmail) { 80: const { stdout } = await execFileNoThrowWithCwd( 81: 'git', 82: [...logArgs, `--author=${userEmail}`], 83: { cwd: getCwd() }, 84: ) 85: tallyInto(stdout) 86: } 87: if (counts.size < 10) { 88: const { stdout } = await execFileNoThrowWithCwd(gitExe(), logArgs, { 89: cwd: getCwd(), 90: }) 91: tallyInto(stdout) 92: } 93: const sorted = Array.from(counts.entries()) 94: .sort((a, b) => b[1] - a[1]) 95: .map(([p]) => p) 96: return pickDiverseCoreFiles(sorted, 5) 97: } catch (err) { 98: logError(err as Error) 99: return [] 100: } 101: } 102: const ONE_WEEK_IN_MS = 7 * 24 * 60 * 60 * 1000 103: export const getExampleCommandFromCache = memoize(() => { 104: const projectConfig = getCurrentProjectConfig() 105: const frequentFile = projectConfig.exampleFiles?.length 106: ? sample(projectConfig.exampleFiles) 107: : '<filepath>' 108: const commands = [ 109: 'fix lint errors', 110: 'fix typecheck errors', 111: `how does ${frequentFile} work?`, 112: `refactor ${frequentFile}`, 113: 'how do I log an error?', 114: `edit ${frequentFile} to...`, 115: `write a test for ${frequentFile}`, 116: 'create a util logging.py that...', 117: ] 118: return `Try "${sample(commands)}"` 119: }) 120: export const refreshExampleCommands = memoize(async (): Promise<void> => { 121: const projectConfig = getCurrentProjectConfig() 122: const now = Date.now() 123: const lastGenerated = projectConfig.exampleFilesGeneratedAt ?? 0 124: if (now - lastGenerated > ONE_WEEK_IN_MS) { 125: projectConfig.exampleFiles = [] 126: } 127: if (!projectConfig.exampleFiles?.length) { 128: void getFrequentlyModifiedFiles().then(files => { 129: if (files.length) { 130: saveCurrentProjectConfig(current => ({ 131: ...current, 132: exampleFiles: files, 133: exampleFilesGeneratedAt: Date.now(), 134: })) 135: } 136: }) 137: } 138: })

File: src/utils/execFileNoThrow.ts

typescript 1: import { type ExecaError, execa } from 'execa' 2: import { getCwd } from '../utils/cwd.js' 3: import { logError } from './log.js' 4: export { execSyncWithDefaults_DEPRECATED } from './execFileNoThrowPortable.js' 5: const MS_IN_SECOND = 1000 6: const SECONDS_IN_MINUTE = 60 7: type ExecFileOptions = { 8: abortSignal?: AbortSignal 9: timeout?: number 10: preserveOutputOnError?: boolean 11: useCwd?: boolean 12: env?: NodeJS.ProcessEnv 13: stdin?: 'ignore' | 'inherit' | 'pipe' 14: input?: string 15: } 16: export function execFileNoThrow( 17: file: string, 18: args: string[], 19: options: ExecFileOptions = { 20: timeout: 10 * SECONDS_IN_MINUTE * MS_IN_SECOND, 21: preserveOutputOnError: true, 22: useCwd: true, 23: }, 24: ): Promise<{ stdout: string; stderr: string; code: number; error?: string }> { 25: return execFileNoThrowWithCwd(file, args, { 26: abortSignal: options.abortSignal, 27: timeout: options.timeout, 28: preserveOutputOnError: options.preserveOutputOnError, 29: cwd: options.useCwd ? getCwd() : undefined, 30: env: options.env, 31: stdin: options.stdin, 32: input: options.input, 33: }) 34: } 35: type ExecFileWithCwdOptions = { 36: abortSignal?: AbortSignal 37: timeout?: number 38: preserveOutputOnError?: boolean 39: maxBuffer?: number 40: cwd?: string 41: env?: NodeJS.ProcessEnv 42: shell?: boolean | string | undefined 43: stdin?: 'ignore' | 'inherit' | 'pipe' 44: input?: string 45: } 46: type ExecaResultWithError = { 47: shortMessage?: string 48: signal?: string 49: } 50: function getErrorMessage( 51: result: ExecaResultWithError, 52: errorCode: number, 53: ): string { 54: if (result.shortMessage) { 55: return result.shortMessage 56: } 57: if (typeof result.signal === 'string') { 58: return result.signal 59: } 60: return String(errorCode) 61: } 62: export function execFileNoThrowWithCwd( 63: file: string, 64: args: string[], 65: { 66: abortSignal, 67: timeout: finalTimeout = 10 * SECONDS_IN_MINUTE * MS_IN_SECOND, 68: preserveOutputOnError: finalPreserveOutput = true, 69: cwd: finalCwd, 70: env: finalEnv, 71: maxBuffer, 72: shell, 73: stdin: finalStdin, 74: input: finalInput, 75: }: ExecFileWithCwdOptions = { 76: timeout: 10 * SECONDS_IN_MINUTE * MS_IN_SECOND, 77: preserveOutputOnError: true, 78: maxBuffer: 1_000_000, 79: }, 80: ): Promise<{ stdout: string; stderr: string; code: number; error?: string }> { 81: return new Promise(resolve => { 82: execa(file, args, { 83: maxBuffer, 84: signal: abortSignal, 85: timeout: finalTimeout, 86: cwd: finalCwd, 87: env: finalEnv, 88: shell, 89: stdin: finalStdin, 90: input: finalInput, 91: reject: false, 92: }) 93: .then(result => { 94: if (result.failed) { 95: if (finalPreserveOutput) { 96: const errorCode = result.exitCode ?? 1 97: void resolve({ 98: stdout: result.stdout || '', 99: stderr: result.stderr || '', 100: code: errorCode, 101: error: getErrorMessage( 102: result as unknown as ExecaResultWithError, 103: errorCode, 104: ), 105: }) 106: } else { 107: void resolve({ stdout: '', stderr: '', code: result.exitCode ?? 1 }) 108: } 109: } else { 110: void resolve({ 111: stdout: result.stdout, 112: stderr: result.stderr, 113: code: 0, 114: }) 115: } 116: }) 117: .catch((error: ExecaError) => { 118: logError(error) 119: void resolve({ stdout: '', stderr: '', code: 1 }) 120: }) 121: }) 122: }

File: src/utils/execFileNoThrowPortable.ts

typescript 1: import { type Options as ExecaOptions, execaSync } from 'execa' 2: import { getCwd } from '../utils/cwd.js' 3: import { slowLogging } from './slowOperations.js' 4: const MS_IN_SECOND = 1000 5: const SECONDS_IN_MINUTE = 60 6: type ExecSyncOptions = { 7: abortSignal?: AbortSignal 8: timeout?: number 9: input?: string 10: stdio?: ExecaOptions['stdio'] 11: } 12: export function execSyncWithDefaults_DEPRECATED(command: string): string | null 13: export function execSyncWithDefaults_DEPRECATED( 14: command: string, 15: options: ExecSyncOptions, 16: ): string | null 17: export function execSyncWithDefaults_DEPRECATED( 18: command: string, 19: abortSignal: AbortSignal, 20: timeout?: number, 21: ): string | null 22: export function execSyncWithDefaults_DEPRECATED( 23: command: string, 24: optionsOrAbortSignal?: ExecSyncOptions | AbortSignal, 25: timeout = 10 * SECONDS_IN_MINUTE * MS_IN_SECOND, 26: ): string | null { 27: let options: ExecSyncOptions 28: if (optionsOrAbortSignal === undefined) { 29: options = {} 30: } else if (optionsOrAbortSignal instanceof AbortSignal) { 31: options = { 32: abortSignal: optionsOrAbortSignal, 33: timeout, 34: } 35: } else { 36: options = optionsOrAbortSignal 37: } 38: const { 39: abortSignal, 40: timeout: finalTimeout = 10 * SECONDS_IN_MINUTE * MS_IN_SECOND, 41: input, 42: stdio = ['ignore', 'pipe', 'pipe'], 43: } = options 44: abortSignal?.throwIfAborted() 45: using _ = slowLogging`exec: ${command.slice(0, 200)}` 46: try { 47: const result = execaSync(command, { 48: env: process.env, 49: maxBuffer: 1_000_000, 50: timeout: finalTimeout, 51: cwd: getCwd(), 52: stdio, 53: shell: true, 54: reject: false, 55: input, 56: }) 57: if (!result.stdout) { 58: return null 59: } 60: return result.stdout.trim() || null 61: } catch { 62: return null 63: } 64: }

File: src/utils/execSyncWrapper.ts

typescript 1: import { 2: type ExecSyncOptions, 3: type ExecSyncOptionsWithBufferEncoding, 4: type ExecSyncOptionsWithStringEncoding, 5: execSync as nodeExecSync, 6: } from 'child_process' 7: import { slowLogging } from './slowOperations.js' 8: export function execSync_DEPRECATED(command: string): Buffer 9: export function execSync_DEPRECATED( 10: command: string, 11: options: ExecSyncOptionsWithStringEncoding, 12: ): string 13: export function execSync_DEPRECATED( 14: command: string, 15: options: ExecSyncOptionsWithBufferEncoding, 16: ): Buffer 17: export function execSync_DEPRECATED( 18: command: string, 19: options?: ExecSyncOptions, 20: ): Buffer | string 21: export function execSync_DEPRECATED( 22: command: string, 23: options?: ExecSyncOptions, 24: ): Buffer | string { 25: using _ = slowLogging`execSync: ${command.slice(0, 100)}` 26: return nodeExecSync(command, options) 27: }

File: src/utils/exportRenderer.tsx

typescript 1: import React, { useRef } from 'react'; 2: import stripAnsi from 'strip-ansi'; 3: import { Messages } from '../components/Messages.js'; 4: import { KeybindingProvider } from '../keybindings/KeybindingContext.js'; 5: import { loadKeybindingsSyncWithWarnings } from '../keybindings/loadUserBindings.js'; 6: import type { KeybindingContextName } from '../keybindings/types.js'; 7: import { AppStateProvider } from '../state/AppState.js'; 8: import type { Tools } from '../Tool.js'; 9: import type { Message } from '../types/message.js'; 10: import { renderToAnsiString } from './staticRender.js'; 11: function StaticKeybindingProvider({ 12: children 13: }: { 14: children: React.ReactNode; 15: }): React.ReactNode { 16: const { 17: bindings 18: } = loadKeybindingsSyncWithWarnings(); 19: const pendingChordRef = useRef(null); 20: const handlerRegistryRef = useRef(new Map()); 21: const activeContexts = useRef(new Set<KeybindingContextName>()).current; 22: return <KeybindingProvider bindings={bindings} pendingChordRef={pendingChordRef} pendingChord={null} setPendingChord={() => {}} activeContexts={activeContexts} registerActiveContext={() => {}} unregisterActiveContext={() => {}} handlerRegistryRef={handlerRegistryRef}> 23: {children} 24: </KeybindingProvider>; 25: } 26: function normalizedUpperBound(m: Message): number { 27: if (!('message' in m)) return 1; 28: const c = m.message.content; 29: return Array.isArray(c) ? c.length : 1; 30: } 31: export async function streamRenderedMessages(messages: Message[], tools: Tools, sink: (ansiChunk: string) => void | Promise<void>, { 32: columns, 33: verbose = false, 34: chunkSize = 40, 35: onProgress 36: }: { 37: columns?: number; 38: verbose?: boolean; 39: chunkSize?: number; 40: onProgress?: (rendered: number) => void; 41: } = {}): Promise<void> { 42: const renderChunk = (range: readonly [number, number]) => renderToAnsiString(<AppStateProvider> 43: <StaticKeybindingProvider> 44: <Messages messages={messages} tools={tools} commands={[]} verbose={verbose} toolJSX={null} toolUseConfirmQueue={[]} inProgressToolUseIDs={new Set()} isMessageSelectorVisible={false} conversationId="export" screen="prompt" streamingToolUses={[]} showAllInTranscript={true} isLoading={false} renderRange={range} /> 45: </StaticKeybindingProvider> 46: </AppStateProvider>, columns); 47: let ceiling = chunkSize; 48: for (const m of messages) ceiling += normalizedUpperBound(m); 49: for (let offset = 0; offset < ceiling; offset += chunkSize) { 50: const ansi = await renderChunk([offset, offset + chunkSize]); 51: if (stripAnsi(ansi).trim() === '') break; 52: await sink(ansi); 53: onProgress?.(offset + chunkSize); 54: } 55: } 56: /** 57: * Renders messages to a plain text string suitable for export. 58: * Uses the same React rendering logic as the interactive UI. 59: */ 60: export async function renderMessagesToPlainText(messages: Message[], tools: Tools = [], columns?: number): Promise<string> { 61: const parts: string[] = []; 62: await streamRenderedMessages(messages, tools, chunk => void parts.push(stripAnsi(chunk)), { 63: columns 64: }); 65: return parts.join(''); 66: }

File: src/utils/extraUsage.ts

typescript 1: import { isClaudeAISubscriber } from './auth.js' 2: import { has1mContext } from './context.js' 3: export function isBilledAsExtraUsage( 4: model: string | null, 5: isFastMode: boolean, 6: isOpus1mMerged: boolean, 7: ): boolean { 8: if (!isClaudeAISubscriber()) return false 9: if (isFastMode) return true 10: if (model === null || !has1mContext(model)) return false 11: const m = model 12: .toLowerCase() 13: .replace(/\[1m\]$/, '') 14: .trim() 15: const isOpus46 = m === 'opus' || m.includes('opus-4-6') 16: const isSonnet46 = m === 'sonnet' || m.includes('sonnet-4-6') 17: if (isOpus46 && isOpus1mMerged) return false 18: return isOpus46 || isSonnet46 19: }

File: src/utils/fastMode.ts

typescript 1: import axios from 'axios' 2: import { getOauthConfig, OAUTH_BETA_HEADER } from 'src/constants/oauth.js' 3: import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' 4: import { 5: getIsNonInteractiveSession, 6: getKairosActive, 7: preferThirdPartyAuthentication, 8: } from '../bootstrap/state.js' 9: import { 10: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 11: logEvent, 12: } from '../services/analytics/index.js' 13: import { 14: getAnthropicApiKey, 15: getClaudeAIOAuthTokens, 16: handleOAuth401Error, 17: hasProfileScope, 18: } from './auth.js' 19: import { isInBundledMode } from './bundledMode.js' 20: import { getGlobalConfig, saveGlobalConfig } from './config.js' 21: import { logForDebugging } from './debug.js' 22: import { isEnvTruthy } from './envUtils.js' 23: import { 24: getDefaultMainLoopModelSetting, 25: isOpus1mMergeEnabled, 26: type ModelSetting, 27: parseUserSpecifiedModel, 28: } from './model/model.js' 29: import { getAPIProvider } from './model/providers.js' 30: import { isEssentialTrafficOnly } from './privacyLevel.js' 31: import { 32: getInitialSettings, 33: getSettingsForSource, 34: updateSettingsForSource, 35: } from './settings/settings.js' 36: import { createSignal } from './signal.js' 37: export function isFastModeEnabled(): boolean { 38: return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FAST_MODE) 39: } 40: export function isFastModeAvailable(): boolean { 41: if (!isFastModeEnabled()) { 42: return false 43: } 44: return getFastModeUnavailableReason() === null 45: } 46: type AuthType = 'oauth' | 'api-key' 47: function getDisabledReasonMessage( 48: disabledReason: FastModeDisabledReason, 49: authType: AuthType, 50: ): string { 51: switch (disabledReason) { 52: case 'free': 53: return authType === 'oauth' 54: ? 'Fast mode requires a paid subscription' 55: : 'Fast mode unavailable during evaluation. Please purchase credits.' 56: case 'preference': 57: return 'Fast mode has been disabled by your organization' 58: case 'extra_usage_disabled': 59: return 'Fast mode requires extra usage billing · /extra-usage to enable' 60: case 'network_error': 61: return 'Fast mode unavailable due to network connectivity issues' 62: case 'unknown': 63: return 'Fast mode is currently unavailable' 64: } 65: } 66: export function getFastModeUnavailableReason(): string | null { 67: if (!isFastModeEnabled()) { 68: return 'Fast mode is not available' 69: } 70: const statigReason = getFeatureValue_CACHED_MAY_BE_STALE( 71: 'tengu_penguins_off', 72: null, 73: ) 74: if (statigReason !== null) { 75: logForDebugging(`Fast mode unavailable: ${statigReason}`) 76: return statigReason 77: } 78: if ( 79: !isInBundledMode() && 80: getFeatureValue_CACHED_MAY_BE_STALE('tengu_marble_sandcastle', false) 81: ) { 82: return 'Fast mode requires the native binary · Install from: https://claude.com/product/claude-code' 83: } 84: if ( 85: getIsNonInteractiveSession() && 86: preferThirdPartyAuthentication() && 87: !getKairosActive() 88: ) { 89: const flagFastMode = getSettingsForSource('flagSettings')?.fastMode 90: if (!flagFastMode) { 91: const reason = 'Fast mode is not available in the Agent SDK' 92: logForDebugging(`Fast mode unavailable: ${reason}`) 93: return reason 94: } 95: } 96: if (getAPIProvider() !== 'firstParty') { 97: const reason = 'Fast mode is not available on Bedrock, Vertex, or Foundry' 98: logForDebugging(`Fast mode unavailable: ${reason}`) 99: return reason 100: } 101: if (orgStatus.status === 'disabled') { 102: if ( 103: orgStatus.reason === 'network_error' || 104: orgStatus.reason === 'unknown' 105: ) { 106: if (isEnvTruthy(process.env.CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS)) { 107: return null 108: } 109: } 110: const authType: AuthType = 111: getClaudeAIOAuthTokens() !== null ? 'oauth' : 'api-key' 112: const reason = getDisabledReasonMessage(orgStatus.reason, authType) 113: logForDebugging(`Fast mode unavailable: ${reason}`) 114: return reason 115: } 116: return null 117: } 118: export const FAST_MODE_MODEL_DISPLAY = 'Opus 4.6' 119: export function getFastModeModel(): string { 120: return 'opus' + (isOpus1mMergeEnabled() ? '[1m]' : '') 121: } 122: export function getInitialFastModeSetting(model: ModelSetting): boolean { 123: if (!isFastModeEnabled()) { 124: return false 125: } 126: if (!isFastModeAvailable()) { 127: return false 128: } 129: if (!isFastModeSupportedByModel(model)) { 130: return false 131: } 132: const settings = getInitialSettings() 133: // If per-session opt-in is required, fast mode starts off each session 134: if (settings.fastModePerSessionOptIn) { 135: return false 136: } 137: return settings.fastMode === true 138: } 139: export function isFastModeSupportedByModel( 140: modelSetting: ModelSetting, 141: ): boolean { 142: if (!isFastModeEnabled()) { 143: return false 144: } 145: const model = modelSetting ?? getDefaultMainLoopModelSetting() 146: const parsedModel = parseUserSpecifiedModel(model) 147: return parsedModel.toLowerCase().includes('opus-4-6') 148: } 149: export type FastModeRuntimeState = 150: | { status: 'active' } 151: | { status: 'cooldown'; resetAt: number; reason: CooldownReason } 152: let runtimeState: FastModeRuntimeState = { status: 'active' } 153: let hasLoggedCooldownExpiry = false 154: export type CooldownReason = 'rate_limit' | 'overloaded' 155: const cooldownTriggered = 156: createSignal<[resetAt: number, reason: CooldownReason]>() 157: const cooldownExpired = createSignal() 158: export const onCooldownTriggered = cooldownTriggered.subscribe 159: export const onCooldownExpired = cooldownExpired.subscribe 160: export function getFastModeRuntimeState(): FastModeRuntimeState { 161: if ( 162: runtimeState.status === 'cooldown' && 163: Date.now() >= runtimeState.resetAt 164: ) { 165: if (isFastModeEnabled() && !hasLoggedCooldownExpiry) { 166: logForDebugging('Fast mode cooldown expired, re-enabling fast mode') 167: hasLoggedCooldownExpiry = true 168: cooldownExpired.emit() 169: } 170: runtimeState = { status: 'active' } 171: } 172: return runtimeState 173: } 174: export function triggerFastModeCooldown( 175: resetTimestamp: number, 176: reason: CooldownReason, 177: ): void { 178: if (!isFastModeEnabled()) { 179: return 180: } 181: runtimeState = { status: 'cooldown', resetAt: resetTimestamp, reason } 182: hasLoggedCooldownExpiry = false 183: const cooldownDurationMs = resetTimestamp - Date.now() 184: logForDebugging( 185: `Fast mode cooldown triggered (${reason}), duration ${Math.round(cooldownDurationMs / 1000)}s`, 186: ) 187: logEvent('tengu_fast_mode_fallback_triggered', { 188: cooldown_duration_ms: cooldownDurationMs, 189: cooldown_reason: 190: reason as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 191: }) 192: cooldownTriggered.emit(resetTimestamp, reason) 193: } 194: export function clearFastModeCooldown(): void { 195: runtimeState = { status: 'active' } 196: } 197: export function handleFastModeRejectedByAPI(): void { 198: if (orgStatus.status === 'disabled') { 199: return 200: } 201: orgStatus = { status: 'disabled', reason: 'preference' } 202: updateSettingsForSource('userSettings', { fastMode: undefined }) 203: saveGlobalConfig(current => ({ 204: ...current, 205: penguinModeOrgEnabled: false, 206: })) 207: orgFastModeChange.emit(false) 208: } 209: const overageRejection = createSignal<[message: string]>() 210: export const onFastModeOverageRejection = overageRejection.subscribe 211: function getOverageDisabledMessage(reason: string | null): string { 212: switch (reason) { 213: case 'out_of_credits': 214: return 'Fast mode disabled · extra usage credits exhausted' 215: case 'org_level_disabled': 216: case 'org_service_level_disabled': 217: return 'Fast mode disabled · extra usage disabled by your organization' 218: case 'org_level_disabled_until': 219: return 'Fast mode disabled · extra usage spending cap reached' 220: case 'member_level_disabled': 221: return 'Fast mode disabled · extra usage disabled for your account' 222: case 'seat_tier_level_disabled': 223: case 'seat_tier_zero_credit_limit': 224: case 'member_zero_credit_limit': 225: return 'Fast mode disabled · extra usage not available for your plan' 226: case 'overage_not_provisioned': 227: case 'no_limits_configured': 228: return 'Fast mode requires extra usage billing · /extra-usage to enable' 229: default: 230: return 'Fast mode disabled · extra usage not available' 231: } 232: } 233: function isOutOfCreditsReason(reason: string | null): boolean { 234: return reason === 'org_level_disabled_until' || reason === 'out_of_credits' 235: } 236: export function handleFastModeOverageRejection(reason: string | null): void { 237: const message = getOverageDisabledMessage(reason) 238: logForDebugging( 239: `Fast mode overage rejection: ${reason ?? 'unknown'} — ${message}`, 240: ) 241: logEvent('tengu_fast_mode_overage_rejected', { 242: overage_disabled_reason: (reason ?? 243: 'unknown') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 244: }) 245: if (!isOutOfCreditsReason(reason)) { 246: updateSettingsForSource('userSettings', { fastMode: undefined }) 247: saveGlobalConfig(current => ({ 248: ...current, 249: penguinModeOrgEnabled: false, 250: })) 251: } 252: overageRejection.emit(message) 253: } 254: export function isFastModeCooldown(): boolean { 255: return getFastModeRuntimeState().status === 'cooldown' 256: } 257: export function getFastModeState( 258: model: ModelSetting, 259: fastModeUserEnabled: boolean | undefined, 260: ): 'off' | 'cooldown' | 'on' { 261: const enabled = 262: isFastModeEnabled() && 263: isFastModeAvailable() && 264: !!fastModeUserEnabled && 265: isFastModeSupportedByModel(model) 266: if (enabled && isFastModeCooldown()) { 267: return 'cooldown' 268: } 269: if (enabled) { 270: return 'on' 271: } 272: return 'off' 273: } 274: export type FastModeDisabledReason = 275: | 'free' 276: | 'preference' 277: | 'extra_usage_disabled' 278: | 'network_error' 279: | 'unknown' 280: type FastModeOrgStatus = 281: | { status: 'pending' } 282: | { status: 'enabled' } 283: | { status: 'disabled'; reason: FastModeDisabledReason } 284: let orgStatus: FastModeOrgStatus = { status: 'pending' } 285: const orgFastModeChange = createSignal<[orgEnabled: boolean]>() 286: export const onOrgFastModeChanged = orgFastModeChange.subscribe 287: type FastModeResponse = { 288: enabled: boolean 289: disabled_reason: FastModeDisabledReason | null 290: } 291: async function fetchFastModeStatus( 292: auth: { accessToken: string } | { apiKey: string }, 293: ): Promise<FastModeResponse> { 294: const endpoint = `${getOauthConfig().BASE_API_URL}/api/claude_code_penguin_mode` 295: const headers: Record<string, string> = 296: 'accessToken' in auth 297: ? { 298: Authorization: `Bearer ${auth.accessToken}`, 299: 'anthropic-beta': OAUTH_BETA_HEADER, 300: } 301: : { 'x-api-key': auth.apiKey } 302: const response = await axios.get<FastModeResponse>(endpoint, { headers }) 303: return response.data 304: } 305: const PREFETCH_MIN_INTERVAL_MS = 30_000 306: let lastPrefetchAt = 0 307: let inflightPrefetch: Promise<void> | null = null 308: export function resolveFastModeStatusFromCache(): void { 309: if (!isFastModeEnabled()) { 310: return 311: } 312: if (orgStatus.status !== 'pending') { 313: return 314: } 315: const isAnt = process.env.USER_TYPE === 'ant' 316: const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true 317: orgStatus = 318: isAnt || cachedEnabled 319: ? { status: 'enabled' } 320: : { status: 'disabled', reason: 'unknown' } 321: } 322: export async function prefetchFastModeStatus(): Promise<void> { 323: if (isEssentialTrafficOnly()) { 324: return 325: } 326: if (!isFastModeEnabled()) { 327: return 328: } 329: if (inflightPrefetch) { 330: logForDebugging( 331: 'Fast mode prefetch in progress, returning in-flight promise', 332: ) 333: return inflightPrefetch 334: } 335: const apiKey = getAnthropicApiKey() 336: const hasUsableOAuth = 337: getClaudeAIOAuthTokens()?.accessToken && hasProfileScope() 338: if (!hasUsableOAuth && !apiKey) { 339: const isAnt = process.env.USER_TYPE === 'ant' 340: const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true 341: orgStatus = 342: isAnt || cachedEnabled 343: ? { status: 'enabled' } 344: : { status: 'disabled', reason: 'preference' } 345: return 346: } 347: const now = Date.now() 348: if (now - lastPrefetchAt < PREFETCH_MIN_INTERVAL_MS) { 349: logForDebugging('Skipping fast mode prefetch, fetched recently') 350: return 351: } 352: lastPrefetchAt = now 353: const fetchWithCurrentAuth = async (): Promise<FastModeResponse> => { 354: const currentTokens = getClaudeAIOAuthTokens() 355: const auth = 356: currentTokens?.accessToken && hasProfileScope() 357: ? { accessToken: currentTokens.accessToken } 358: : apiKey 359: ? { apiKey } 360: : null 361: if (!auth) { 362: throw new Error('No auth available') 363: } 364: return fetchFastModeStatus(auth) 365: } 366: async function doFetch(): Promise<void> { 367: try { 368: let status: FastModeResponse 369: try { 370: status = await fetchWithCurrentAuth() 371: } catch (err) { 372: const isAuthError = 373: axios.isAxiosError(err) && 374: (err.response?.status === 401 || 375: (err.response?.status === 403 && 376: typeof err.response?.data === 'string' && 377: err.response.data.includes('OAuth token has been revoked'))) 378: if (isAuthError) { 379: const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken 380: if (failedAccessToken) { 381: await handleOAuth401Error(failedAccessToken) 382: status = await fetchWithCurrentAuth() 383: } else { 384: throw err 385: } 386: } else { 387: throw err 388: } 389: } 390: const previousEnabled = 391: orgStatus.status !== 'pending' 392: ? orgStatus.status === 'enabled' 393: : getGlobalConfig().penguinModeOrgEnabled 394: orgStatus = status.enabled 395: ? { status: 'enabled' } 396: : { 397: status: 'disabled', 398: reason: status.disabled_reason ?? 'preference', 399: } 400: if (previousEnabled !== status.enabled) { 401: if (!status.enabled) { 402: updateSettingsForSource('userSettings', { fastMode: undefined }) 403: } 404: saveGlobalConfig(current => ({ 405: ...current, 406: penguinModeOrgEnabled: status.enabled, 407: })) 408: orgFastModeChange.emit(status.enabled) 409: } 410: logForDebugging( 411: `Org fast mode: ${status.enabled ? 'enabled' : `disabled (${status.disabled_reason ?? 'preference'})`}`, 412: ) 413: } catch (err) { 414: const isAnt = process.env.USER_TYPE === 'ant' 415: const cachedEnabled = getGlobalConfig().penguinModeOrgEnabled === true 416: orgStatus = 417: isAnt || cachedEnabled 418: ? { status: 'enabled' } 419: : { status: 'disabled', reason: 'network_error' } 420: logForDebugging( 421: `Failed to fetch org fast mode status, defaulting to ${orgStatus.status === 'enabled' ? 'enabled (cached)' : 'disabled (network_error)'}: ${err}`, 422: { level: 'error' }, 423: ) 424: logEvent('tengu_org_penguin_mode_fetch_failed', {}) 425: } finally { 426: inflightPrefetch = null 427: } 428: } 429: inflightPrefetch = doFetch() 430: return inflightPrefetch 431: }

File: src/utils/file.ts

typescript 1: import { chmodSync, writeFileSync as fsWriteFileSync } from 'fs' 2: import { realpath, stat } from 'fs/promises' 3: import { homedir } from 'os' 4: import { 5: basename, 6: dirname, 7: extname, 8: isAbsolute, 9: join, 10: normalize, 11: relative, 12: resolve, 13: sep, 14: } from 'path' 15: import { logEvent } from 'src/services/analytics/index.js' 16: import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' 17: import { getCwd } from '../utils/cwd.js' 18: import { logForDebugging } from './debug.js' 19: import { isENOENT, isFsInaccessible } from './errors.js' 20: import { 21: detectEncodingForResolvedPath, 22: detectLineEndingsForString, 23: type LineEndingType, 24: } from './fileRead.js' 25: import { fileReadCache } from './fileReadCache.js' 26: import { getFsImplementation, safeResolvePath } from './fsOperations.js' 27: import { logError } from './log.js' 28: import { expandPath } from './path.js' 29: import { getPlatform } from './platform.js' 30: export type File = { 31: filename: string 32: content: string 33: } 34: export async function pathExists(path: string): Promise<boolean> { 35: try { 36: await stat(path) 37: return true 38: } catch { 39: return false 40: } 41: } 42: export const MAX_OUTPUT_SIZE = 0.25 * 1024 * 1024 43: export function readFileSafe(filepath: string): string | null { 44: try { 45: const fs = getFsImplementation() 46: return fs.readFileSync(filepath, { encoding: 'utf8' }) 47: } catch (error) { 48: logError(error) 49: return null 50: } 51: } 52: export function getFileModificationTime(filePath: string): number { 53: const fs = getFsImplementation() 54: return Math.floor(fs.statSync(filePath).mtimeMs) 55: } 56: export async function getFileModificationTimeAsync( 57: filePath: string, 58: ): Promise<number> { 59: const s = await getFsImplementation().stat(filePath) 60: return Math.floor(s.mtimeMs) 61: } 62: export function writeTextContent( 63: filePath: string, 64: content: string, 65: encoding: BufferEncoding, 66: endings: LineEndingType, 67: ): void { 68: let toWrite = content 69: if (endings === 'CRLF') { 70: toWrite = content.replaceAll('\r\n', '\n').split('\n').join('\r\n') 71: } 72: writeFileSyncAndFlush_DEPRECATED(filePath, toWrite, { encoding }) 73: } 74: export function detectFileEncoding(filePath: string): BufferEncoding { 75: try { 76: const fs = getFsImplementation() 77: const { resolvedPath } = safeResolvePath(fs, filePath) 78: return detectEncodingForResolvedPath(resolvedPath) 79: } catch (error) { 80: if (isFsInaccessible(error)) { 81: logForDebugging( 82: `detectFileEncoding failed for expected reason: ${error.code}`, 83: { 84: level: 'debug', 85: }, 86: ) 87: } else { 88: logError(error) 89: } 90: return 'utf8' 91: } 92: } 93: export function detectLineEndings( 94: filePath: string, 95: encoding: BufferEncoding = 'utf8', 96: ): LineEndingType { 97: try { 98: const fs = getFsImplementation() 99: const { resolvedPath } = safeResolvePath(fs, filePath) 100: const { buffer, bytesRead } = fs.readSync(resolvedPath, { length: 4096 }) 101: const content = buffer.toString(encoding, 0, bytesRead) 102: return detectLineEndingsForString(content) 103: } catch (error) { 104: logError(error) 105: return 'LF' 106: } 107: } 108: export function convertLeadingTabsToSpaces(content: string): string { 109: if (!content.includes('\t')) return content 110: return content.replace(/^\t+/gm, _ => ' '.repeat(_.length)) 111: } 112: export function getAbsoluteAndRelativePaths(path: string | undefined): { 113: absolutePath: string | undefined 114: relativePath: string | undefined 115: } { 116: const absolutePath = path ? expandPath(path) : undefined 117: const relativePath = absolutePath 118: ? relative(getCwd(), absolutePath) 119: : undefined 120: return { absolutePath, relativePath } 121: } 122: export function getDisplayPath(filePath: string): string { 123: const { relativePath } = getAbsoluteAndRelativePaths(filePath) 124: if (relativePath && !relativePath.startsWith('..')) { 125: return relativePath 126: } 127: const homeDir = homedir() 128: if (filePath.startsWith(homeDir + sep)) { 129: return '~' + filePath.slice(homeDir.length) 130: } 131: return filePath 132: } 133: export function findSimilarFile(filePath: string): string | undefined { 134: const fs = getFsImplementation() 135: try { 136: const dir = dirname(filePath) 137: const fileBaseName = basename(filePath, extname(filePath)) 138: const files = fs.readdirSync(dir) 139: const similarFiles = files.filter( 140: file => 141: basename(file.name, extname(file.name)) === fileBaseName && 142: join(dir, file.name) !== filePath, 143: ) 144: const firstMatch = similarFiles[0] 145: if (firstMatch) { 146: return firstMatch.name 147: } 148: return undefined 149: } catch (error) { 150: if (!isENOENT(error)) { 151: logError(error) 152: } 153: return undefined 154: } 155: } 156: export const FILE_NOT_FOUND_CWD_NOTE = 'Note: your current working directory is' 157: export async function suggestPathUnderCwd( 158: requestedPath: string, 159: ): Promise<string | undefined> { 160: const cwd = getCwd() 161: const cwdParent = dirname(cwd) 162: let resolvedPath = requestedPath 163: try { 164: const resolvedDir = await realpath(dirname(requestedPath)) 165: resolvedPath = join(resolvedDir, basename(requestedPath)) 166: } catch { 167: } 168: const cwdParentPrefix = cwdParent === sep ? sep : cwdParent + sep 169: if ( 170: !resolvedPath.startsWith(cwdParentPrefix) || 171: resolvedPath.startsWith(cwd + sep) || 172: resolvedPath === cwd 173: ) { 174: return undefined 175: } 176: const relFromParent = relative(cwdParent, resolvedPath) 177: const correctedPath = join(cwd, relFromParent) 178: try { 179: await stat(correctedPath) 180: return correctedPath 181: } catch { 182: return undefined 183: } 184: } 185: export function isCompactLinePrefixEnabled(): boolean { 186: return !getFeatureValue_CACHED_MAY_BE_STALE( 187: 'tengu_compact_line_prefix_killswitch', 188: false, 189: ) 190: } 191: export function addLineNumbers({ 192: content, 193: startLine, 194: }: { 195: content: string 196: startLine: number 197: }): string { 198: if (!content) { 199: return '' 200: } 201: const lines = content.split(/\r?\n/) 202: if (isCompactLinePrefixEnabled()) { 203: return lines 204: .map((line, index) => `${index + startLine}\t${line}`) 205: .join('\n') 206: } 207: return lines 208: .map((line, index) => { 209: const numStr = String(index + startLine) 210: if (numStr.length >= 6) { 211: return `${numStr}→${line}` 212: } 213: return `${numStr.padStart(6, ' ')}→${line}` 214: }) 215: .join('\n') 216: } 217: export function stripLineNumberPrefix(line: string): string { 218: const match = line.match(/^\s*\d+[\u2192\t](.*)$/) 219: return match?.[1] ?? line 220: } 221: export function isDirEmpty(dirPath: string): boolean { 222: try { 223: return getFsImplementation().isDirEmptySync(dirPath) 224: } catch (e) { 225: return isENOENT(e) 226: } 227: } 228: export function readFileSyncCached(filePath: string): string { 229: const { content } = fileReadCache.readFile(filePath) 230: return content 231: } 232: export function writeFileSyncAndFlush_DEPRECATED( 233: filePath: string, 234: content: string, 235: options: { encoding: BufferEncoding; mode?: number } = { encoding: 'utf-8' }, 236: ): void { 237: const fs = getFsImplementation() 238: let targetPath = filePath 239: try { 240: const linkTarget = fs.readlinkSync(filePath) 241: targetPath = isAbsolute(linkTarget) 242: ? linkTarget 243: : resolve(dirname(filePath), linkTarget) 244: logForDebugging(`Writing through symlink: ${filePath} -> ${targetPath}`) 245: } catch { 246: } 247: const tempPath = `${targetPath}.tmp.${process.pid}.${Date.now()}` 248: let targetMode: number | undefined 249: let targetExists = false 250: try { 251: targetMode = fs.statSync(targetPath).mode 252: targetExists = true 253: logForDebugging(`Preserving file permissions: ${targetMode.toString(8)}`) 254: } catch (e) { 255: if (!isENOENT(e)) throw e 256: if (options.mode !== undefined) { 257: targetMode = options.mode 258: logForDebugging( 259: `Setting permissions for new file: ${targetMode.toString(8)}`, 260: ) 261: } 262: } 263: try { 264: logForDebugging(`Writing to temp file: ${tempPath}`) 265: const writeOptions: { 266: encoding: BufferEncoding 267: flush: boolean 268: mode?: number 269: } = { 270: encoding: options.encoding, 271: flush: true, 272: } 273: if (!targetExists && options.mode !== undefined) { 274: writeOptions.mode = options.mode 275: } 276: fsWriteFileSync(tempPath, content, writeOptions) 277: logForDebugging( 278: `Temp file written successfully, size: ${content.length} bytes`, 279: ) 280: if (targetExists && targetMode !== undefined) { 281: chmodSync(tempPath, targetMode) 282: logForDebugging(`Applied original permissions to temp file`) 283: } 284: logForDebugging(`Renaming ${tempPath} to ${targetPath}`) 285: fs.renameSync(tempPath, targetPath) 286: logForDebugging(`File ${targetPath} written atomically`) 287: } catch (atomicError) { 288: logForDebugging(`Failed to write file atomically: ${atomicError}`, { 289: level: 'error', 290: }) 291: logEvent('tengu_atomic_write_error', {}) 292: try { 293: logForDebugging(`Cleaning up temp file: ${tempPath}`) 294: fs.unlinkSync(tempPath) 295: } catch (cleanupError) { 296: logForDebugging(`Failed to clean up temp file: ${cleanupError}`) 297: } 298: logForDebugging(`Falling back to non-atomic write for ${targetPath}`) 299: try { 300: const fallbackOptions: { 301: encoding: BufferEncoding 302: flush: boolean 303: mode?: number 304: } = { 305: encoding: options.encoding, 306: flush: true, 307: } 308: if (!targetExists && options.mode !== undefined) { 309: fallbackOptions.mode = options.mode 310: } 311: fsWriteFileSync(targetPath, content, fallbackOptions) 312: logForDebugging( 313: `File ${targetPath} written successfully with non-atomic fallback`, 314: ) 315: } catch (fallbackError) { 316: logForDebugging(`Non-atomic write also failed: ${fallbackError}`) 317: throw fallbackError 318: } 319: } 320: } 321: export function getDesktopPath(): string { 322: const platform = getPlatform() 323: const homeDir = homedir() 324: if (platform === 'macos') { 325: return join(homeDir, 'Desktop') 326: } 327: if (platform === 'windows') { 328: const windowsHome = process.env.USERPROFILE 329: ? process.env.USERPROFILE.replace(/\\/g, '/') 330: : null 331: if (windowsHome) { 332: const wslPath = windowsHome.replace(/^[A-Z]:/, '') 333: const desktopPath = `/mnt/c${wslPath}/Desktop` 334: if (getFsImplementation().existsSync(desktopPath)) { 335: return desktopPath 336: } 337: } 338: // Fallback: try to find desktop in typical Windows user location 339: try { 340: const usersDir = '/mnt/c/Users' 341: const userDirs = getFsImplementation().readdirSync(usersDir) 342: for (const user of userDirs) { 343: if ( 344: user.name === 'Public' || 345: user.name === 'Default' || 346: user.name === 'Default User' || 347: user.name === 'All Users' 348: ) { 349: continue 350: } 351: const potentialDesktopPath = join(usersDir, user.name, 'Desktop') 352: if (getFsImplementation().existsSync(potentialDesktopPath)) { 353: return potentialDesktopPath 354: } 355: } 356: } catch (error) { 357: logError(error) 358: } 359: } 360: const desktopPath = join(homeDir, 'Desktop') 361: if (getFsImplementation().existsSync(desktopPath)) { 362: return desktopPath 363: } 364: return homeDir 365: } 366: export function isFileWithinReadSizeLimit( 367: filePath: string, 368: maxSizeBytes: number = MAX_OUTPUT_SIZE, 369: ): boolean { 370: try { 371: const stats = getFsImplementation().statSync(filePath) 372: return stats.size <= maxSizeBytes 373: } catch { 374: return false 375: } 376: } 377: export function normalizePathForComparison(filePath: string): string { 378: let normalized = normalize(filePath) 379: if (getPlatform() === 'windows') { 380: normalized = normalized.replace(/\//g, '\\').toLowerCase() 381: } 382: return normalized 383: } 384: export function pathsEqual(path1: string, path2: string): boolean { 385: return normalizePathForComparison(path1) === normalizePathForComparison(path2) 386: }

File: src/utils/fileHistory.ts

typescript 1: import { createHash, type UUID } from 'crypto' 2: import { diffLines } from 'diff' 3: import type { Stats } from 'fs' 4: import { 5: chmod, 6: copyFile, 7: link, 8: mkdir, 9: readFile, 10: stat, 11: unlink, 12: } from 'fs/promises' 13: import { dirname, isAbsolute, join, relative } from 'path' 14: import { 15: getIsNonInteractiveSession, 16: getOriginalCwd, 17: getSessionId, 18: } from 'src/bootstrap/state.js' 19: import { logEvent } from 'src/services/analytics/index.js' 20: import { notifyVscodeFileUpdated } from 'src/services/mcp/vscodeSdkMcp.js' 21: import type { LogOption } from 'src/types/logs.js' 22: import { inspect } from 'util' 23: import { getGlobalConfig } from './config.js' 24: import { logForDebugging } from './debug.js' 25: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 26: import { getErrnoCode, isENOENT } from './errors.js' 27: import { pathExists } from './file.js' 28: import { logError } from './log.js' 29: import { recordFileHistorySnapshot } from './sessionStorage.js' 30: type BackupFileName = string | null 31: export type FileHistoryBackup = { 32: backupFileName: BackupFileName 33: version: number 34: backupTime: Date 35: } 36: export type FileHistorySnapshot = { 37: messageId: UUID 38: trackedFileBackups: Record<string, FileHistoryBackup> 39: timestamp: Date 40: } 41: export type FileHistoryState = { 42: snapshots: FileHistorySnapshot[] 43: trackedFiles: Set<string> 44: snapshotSequence: number 45: } 46: const MAX_SNAPSHOTS = 100 47: export type DiffStats = 48: | { 49: filesChanged?: string[] 50: insertions: number 51: deletions: number 52: } 53: | undefined 54: export function fileHistoryEnabled(): boolean { 55: if (getIsNonInteractiveSession()) { 56: return fileHistoryEnabledSdk() 57: } 58: return ( 59: getGlobalConfig().fileCheckpointingEnabled !== false && 60: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING) 61: ) 62: } 63: function fileHistoryEnabledSdk(): boolean { 64: return ( 65: isEnvTruthy(process.env.CLAUDE_CODE_ENABLE_SDK_FILE_CHECKPOINTING) && 66: !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING) 67: ) 68: } 69: export async function fileHistoryTrackEdit( 70: updateFileHistoryState: ( 71: updater: (prev: FileHistoryState) => FileHistoryState, 72: ) => void, 73: filePath: string, 74: messageId: UUID, 75: ): Promise<void> { 76: if (!fileHistoryEnabled()) { 77: return 78: } 79: const trackingPath = maybeShortenFilePath(filePath) 80: let captured: FileHistoryState | undefined 81: updateFileHistoryState(state => { 82: captured = state 83: return state 84: }) 85: if (!captured) return 86: const mostRecent = captured.snapshots.at(-1) 87: if (!mostRecent) { 88: logError(new Error('FileHistory: Missing most recent snapshot')) 89: logEvent('tengu_file_history_track_edit_failed', {}) 90: return 91: } 92: if (mostRecent.trackedFileBackups[trackingPath]) { 93: return 94: } 95: let backup: FileHistoryBackup 96: try { 97: backup = await createBackup(filePath, 1) 98: } catch (error) { 99: logError(error) 100: logEvent('tengu_file_history_track_edit_failed', {}) 101: return 102: } 103: const isAddingFile = backup.backupFileName === null 104: updateFileHistoryState((state: FileHistoryState) => { 105: try { 106: const mostRecentSnapshot = state.snapshots.at(-1) 107: if ( 108: !mostRecentSnapshot || 109: mostRecentSnapshot.trackedFileBackups[trackingPath] 110: ) { 111: return state 112: } 113: const updatedTrackedFiles = state.trackedFiles.has(trackingPath) 114: ? state.trackedFiles 115: : new Set(state.trackedFiles).add(trackingPath) 116: const updatedMostRecentSnapshot = { 117: ...mostRecentSnapshot, 118: trackedFileBackups: { 119: ...mostRecentSnapshot.trackedFileBackups, 120: [trackingPath]: backup, 121: }, 122: } 123: const updatedState = { 124: ...state, 125: snapshots: (() => { 126: const copy = state.snapshots.slice() 127: copy[copy.length - 1] = updatedMostRecentSnapshot 128: return copy 129: })(), 130: trackedFiles: updatedTrackedFiles, 131: } 132: maybeDumpStateForDebug(updatedState) 133: void recordFileHistorySnapshot( 134: messageId, 135: updatedMostRecentSnapshot, 136: true, 137: ).catch(error => { 138: logError(new Error(`FileHistory: Failed to record snapshot: ${error}`)) 139: }) 140: logEvent('tengu_file_history_track_edit_success', { 141: isNewFile: isAddingFile, 142: version: backup.version, 143: }) 144: logForDebugging(`FileHistory: Tracked file modification for ${filePath}`) 145: return updatedState 146: } catch (error) { 147: logError(error) 148: logEvent('tengu_file_history_track_edit_failed', {}) 149: return state 150: } 151: }) 152: } 153: export async function fileHistoryMakeSnapshot( 154: updateFileHistoryState: ( 155: updater: (prev: FileHistoryState) => FileHistoryState, 156: ) => void, 157: messageId: UUID, 158: ): Promise<void> { 159: if (!fileHistoryEnabled()) { 160: return undefined 161: } 162: let captured: FileHistoryState | undefined 163: updateFileHistoryState(state => { 164: captured = state 165: return state 166: }) 167: if (!captured) return 168: const trackedFileBackups: Record<string, FileHistoryBackup> = {} 169: const mostRecentSnapshot = captured.snapshots.at(-1) 170: if (mostRecentSnapshot) { 171: logForDebugging(`FileHistory: Making snapshot for message ${messageId}`) 172: await Promise.all( 173: Array.from(captured.trackedFiles, async trackingPath => { 174: try { 175: const filePath = maybeExpandFilePath(trackingPath) 176: const latestBackup = 177: mostRecentSnapshot.trackedFileBackups[trackingPath] 178: const nextVersion = latestBackup ? latestBackup.version + 1 : 1 179: let fileStats: Stats | undefined 180: try { 181: fileStats = await stat(filePath) 182: } catch (e: unknown) { 183: if (!isENOENT(e)) throw e 184: } 185: if (!fileStats) { 186: trackedFileBackups[trackingPath] = { 187: backupFileName: null, 188: version: nextVersion, 189: backupTime: new Date(), 190: } 191: logEvent('tengu_file_history_backup_deleted_file', { 192: version: nextVersion, 193: }) 194: logForDebugging( 195: `FileHistory: Missing tracked file: ${trackingPath}`, 196: ) 197: return 198: } 199: if ( 200: latestBackup && 201: latestBackup.backupFileName !== null && 202: !(await checkOriginFileChanged( 203: filePath, 204: latestBackup.backupFileName, 205: fileStats, 206: )) 207: ) { 208: trackedFileBackups[trackingPath] = latestBackup 209: return 210: } 211: trackedFileBackups[trackingPath] = await createBackup( 212: filePath, 213: nextVersion, 214: ) 215: } catch (error) { 216: logError(error) 217: logEvent('tengu_file_history_backup_file_failed', {}) 218: } 219: }), 220: ) 221: } 222: updateFileHistoryState((state: FileHistoryState) => { 223: try { 224: const lastSnapshot = state.snapshots.at(-1) 225: if (lastSnapshot) { 226: for (const trackingPath of state.trackedFiles) { 227: if (trackingPath in trackedFileBackups) continue 228: const inherited = lastSnapshot.trackedFileBackups[trackingPath] 229: if (inherited) trackedFileBackups[trackingPath] = inherited 230: } 231: } 232: const now = new Date() 233: const newSnapshot: FileHistorySnapshot = { 234: messageId, 235: trackedFileBackups, 236: timestamp: now, 237: } 238: const allSnapshots = [...state.snapshots, newSnapshot] 239: const updatedState: FileHistoryState = { 240: ...state, 241: snapshots: 242: allSnapshots.length > MAX_SNAPSHOTS 243: ? allSnapshots.slice(-MAX_SNAPSHOTS) 244: : allSnapshots, 245: snapshotSequence: (state.snapshotSequence ?? 0) + 1, 246: } 247: maybeDumpStateForDebug(updatedState) 248: void notifyVscodeSnapshotFilesUpdated(state, updatedState).catch(logError) 249: void recordFileHistorySnapshot( 250: messageId, 251: newSnapshot, 252: false, 253: ).catch(error => { 254: logError(new Error(`FileHistory: Failed to record snapshot: ${error}`)) 255: }) 256: logForDebugging( 257: `FileHistory: Added snapshot for ${messageId}, tracking ${state.trackedFiles.size} files`, 258: ) 259: logEvent('tengu_file_history_snapshot_success', { 260: trackedFilesCount: state.trackedFiles.size, 261: snapshotCount: updatedState.snapshots.length, 262: }) 263: return updatedState 264: } catch (error) { 265: logError(error) 266: logEvent('tengu_file_history_snapshot_failed', {}) 267: return state 268: } 269: }) 270: } 271: export async function fileHistoryRewind( 272: updateFileHistoryState: ( 273: updater: (prev: FileHistoryState) => FileHistoryState, 274: ) => void, 275: messageId: UUID, 276: ): Promise<void> { 277: if (!fileHistoryEnabled()) { 278: return 279: } 280: let captured: FileHistoryState | undefined 281: updateFileHistoryState(state => { 282: captured = state 283: return state 284: }) 285: if (!captured) return 286: const targetSnapshot = captured.snapshots.findLast( 287: snapshot => snapshot.messageId === messageId, 288: ) 289: if (!targetSnapshot) { 290: logError(new Error(`FileHistory: Snapshot for ${messageId} not found`)) 291: logEvent('tengu_file_history_rewind_failed', { 292: trackedFilesCount: captured.trackedFiles.size, 293: snapshotFound: false, 294: }) 295: throw new Error('The selected snapshot was not found') 296: } 297: try { 298: logForDebugging( 299: `FileHistory: [Rewind] Rewinding to snapshot for ${messageId}`, 300: ) 301: const filesChanged = await applySnapshot(captured, targetSnapshot) 302: logForDebugging(`FileHistory: [Rewind] Finished rewinding to ${messageId}`) 303: logEvent('tengu_file_history_rewind_success', { 304: trackedFilesCount: captured.trackedFiles.size, 305: filesChangedCount: filesChanged.length, 306: }) 307: } catch (error) { 308: logError(error) 309: logEvent('tengu_file_history_rewind_failed', { 310: trackedFilesCount: captured.trackedFiles.size, 311: snapshotFound: true, 312: }) 313: throw error 314: } 315: } 316: export function fileHistoryCanRestore( 317: state: FileHistoryState, 318: messageId: UUID, 319: ): boolean { 320: if (!fileHistoryEnabled()) { 321: return false 322: } 323: return state.snapshots.some(snapshot => snapshot.messageId === messageId) 324: } 325: export async function fileHistoryGetDiffStats( 326: state: FileHistoryState, 327: messageId: UUID, 328: ): Promise<DiffStats> { 329: if (!fileHistoryEnabled()) { 330: return undefined 331: } 332: const targetSnapshot = state.snapshots.findLast( 333: snapshot => snapshot.messageId === messageId, 334: ) 335: if (!targetSnapshot) { 336: return undefined 337: } 338: const results = await Promise.all( 339: Array.from(state.trackedFiles, async trackingPath => { 340: try { 341: const filePath = maybeExpandFilePath(trackingPath) 342: const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] 343: const backupFileName: BackupFileName | undefined = targetBackup 344: ? targetBackup.backupFileName 345: : getBackupFileNameFirstVersion(trackingPath, state) 346: if (backupFileName === undefined) { 347: logError( 348: new Error('FileHistory: Error finding the backup file to apply'), 349: ) 350: logEvent('tengu_file_history_rewind_restore_file_failed', { 351: dryRun: true, 352: }) 353: return null 354: } 355: const stats = await computeDiffStatsForFile( 356: filePath, 357: backupFileName === null ? undefined : backupFileName, 358: ) 359: if (stats?.insertions || stats?.deletions) { 360: return { filePath, stats } 361: } 362: if (backupFileName === null && (await pathExists(filePath))) { 363: return { filePath, stats } 364: } 365: return null 366: } catch (error) { 367: logError(error) 368: logEvent('tengu_file_history_rewind_restore_file_failed', { 369: dryRun: true, 370: }) 371: return null 372: } 373: }), 374: ) 375: const filesChanged: string[] = [] 376: let insertions = 0 377: let deletions = 0 378: for (const r of results) { 379: if (!r) continue 380: filesChanged.push(r.filePath) 381: insertions += r.stats?.insertions || 0 382: deletions += r.stats?.deletions || 0 383: } 384: return { filesChanged, insertions, deletions } 385: } 386: export async function fileHistoryHasAnyChanges( 387: state: FileHistoryState, 388: messageId: UUID, 389: ): Promise<boolean> { 390: if (!fileHistoryEnabled()) { 391: return false 392: } 393: const targetSnapshot = state.snapshots.findLast( 394: snapshot => snapshot.messageId === messageId, 395: ) 396: if (!targetSnapshot) { 397: return false 398: } 399: for (const trackingPath of state.trackedFiles) { 400: try { 401: const filePath = maybeExpandFilePath(trackingPath) 402: const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] 403: const backupFileName: BackupFileName | undefined = targetBackup 404: ? targetBackup.backupFileName 405: : getBackupFileNameFirstVersion(trackingPath, state) 406: if (backupFileName === undefined) { 407: continue 408: } 409: if (backupFileName === null) { 410: if (await pathExists(filePath)) return true 411: continue 412: } 413: if (await checkOriginFileChanged(filePath, backupFileName)) return true 414: } catch (error) { 415: logError(error) 416: } 417: } 418: return false 419: } 420: async function applySnapshot( 421: state: FileHistoryState, 422: targetSnapshot: FileHistorySnapshot, 423: ): Promise<string[]> { 424: const filesChanged: string[] = [] 425: for (const trackingPath of state.trackedFiles) { 426: try { 427: const filePath = maybeExpandFilePath(trackingPath) 428: const targetBackup = targetSnapshot.trackedFileBackups[trackingPath] 429: const backupFileName: BackupFileName | undefined = targetBackup 430: ? targetBackup.backupFileName 431: : getBackupFileNameFirstVersion(trackingPath, state) 432: if (backupFileName === undefined) { 433: logError( 434: new Error('FileHistory: Error finding the backup file to apply'), 435: ) 436: logEvent('tengu_file_history_rewind_restore_file_failed', { 437: dryRun: false, 438: }) 439: continue 440: } 441: if (backupFileName === null) { 442: try { 443: await unlink(filePath) 444: logForDebugging(`FileHistory: [Rewind] Deleted ${filePath}`) 445: filesChanged.push(filePath) 446: } catch (e: unknown) { 447: if (!isENOENT(e)) throw e 448: } 449: continue 450: } 451: if (await checkOriginFileChanged(filePath, backupFileName)) { 452: await restoreBackup(filePath, backupFileName) 453: logForDebugging( 454: `FileHistory: [Rewind] Restored ${filePath} from ${backupFileName}`, 455: ) 456: filesChanged.push(filePath) 457: } 458: } catch (error) { 459: logError(error) 460: logEvent('tengu_file_history_rewind_restore_file_failed', { 461: dryRun: false, 462: }) 463: } 464: } 465: return filesChanged 466: } 467: export async function checkOriginFileChanged( 468: originalFile: string, 469: backupFileName: string, 470: originalStatsHint?: Stats, 471: ): Promise<boolean> { 472: const backupPath = resolveBackupPath(backupFileName) 473: let originalStats: Stats | null = originalStatsHint ?? null 474: if (!originalStats) { 475: try { 476: originalStats = await stat(originalFile) 477: } catch (e: unknown) { 478: if (!isENOENT(e)) return true 479: } 480: } 481: let backupStats: Stats | null = null 482: try { 483: backupStats = await stat(backupPath) 484: } catch (e: unknown) { 485: if (!isENOENT(e)) return true 486: } 487: return compareStatsAndContent(originalStats, backupStats, async () => { 488: try { 489: const [originalContent, backupContent] = await Promise.all([ 490: readFile(originalFile, 'utf-8'), 491: readFile(backupPath, 'utf-8'), 492: ]) 493: return originalContent !== backupContent 494: } catch { 495: return true 496: } 497: }) 498: } 499: function compareStatsAndContent<T extends boolean | Promise<boolean>>( 500: originalStats: Stats | null, 501: backupStats: Stats | null, 502: compareContent: () => T, 503: ): T | boolean { 504: if ((originalStats === null) !== (backupStats === null)) { 505: return true 506: } 507: if (originalStats === null || backupStats === null) { 508: return false 509: } 510: if ( 511: originalStats.mode !== backupStats.mode || 512: originalStats.size !== backupStats.size 513: ) { 514: return true 515: } 516: if (originalStats.mtimeMs < backupStats.mtimeMs) { 517: return false 518: } 519: return compareContent() 520: } 521: async function computeDiffStatsForFile( 522: originalFile: string, 523: backupFileName?: string, 524: ): Promise<DiffStats> { 525: const filesChanged: string[] = [] 526: let insertions = 0 527: let deletions = 0 528: try { 529: const backupPath = backupFileName 530: ? resolveBackupPath(backupFileName) 531: : undefined 532: const [originalContent, backupContent] = await Promise.all([ 533: readFileAsyncOrNull(originalFile), 534: backupPath ? readFileAsyncOrNull(backupPath) : null, 535: ]) 536: if (originalContent === null && backupContent === null) { 537: return { 538: filesChanged, 539: insertions, 540: deletions, 541: } 542: } 543: filesChanged.push(originalFile) 544: const changes = diffLines(originalContent ?? '', backupContent ?? '') 545: changes.forEach(c => { 546: if (c.added) { 547: insertions += c.count || 0 548: } 549: if (c.removed) { 550: deletions += c.count || 0 551: } 552: }) 553: } catch (error) { 554: logError(new Error(`FileHistory: Error generating diffStats: ${error}`)) 555: } 556: return { 557: filesChanged, 558: insertions, 559: deletions, 560: } 561: } 562: function getBackupFileName(filePath: string, version: number): string { 563: const fileNameHash = createHash('sha256') 564: .update(filePath) 565: .digest('hex') 566: .slice(0, 16) 567: return `${fileNameHash}@v${version}` 568: } 569: function resolveBackupPath(backupFileName: string, sessionId?: string): string { 570: const configDir = getClaudeConfigHomeDir() 571: return join( 572: configDir, 573: 'file-history', 574: sessionId || getSessionId(), 575: backupFileName, 576: ) 577: } 578: async function createBackup( 579: filePath: string | null, 580: version: number, 581: ): Promise<FileHistoryBackup> { 582: if (filePath === null) { 583: return { backupFileName: null, version, backupTime: new Date() } 584: } 585: const backupFileName = getBackupFileName(filePath, version) 586: const backupPath = resolveBackupPath(backupFileName) 587: let srcStats: Stats 588: try { 589: srcStats = await stat(filePath) 590: } catch (e: unknown) { 591: if (isENOENT(e)) { 592: return { backupFileName: null, version, backupTime: new Date() } 593: } 594: throw e 595: } 596: try { 597: await copyFile(filePath, backupPath) 598: } catch (e: unknown) { 599: if (!isENOENT(e)) throw e 600: await mkdir(dirname(backupPath), { recursive: true }) 601: await copyFile(filePath, backupPath) 602: } 603: await chmod(backupPath, srcStats.mode) 604: logEvent('tengu_file_history_backup_file_created', { 605: version: version, 606: fileSize: srcStats.size, 607: }) 608: return { 609: backupFileName, 610: version, 611: backupTime: new Date(), 612: } 613: } 614: async function restoreBackup( 615: filePath: string, 616: backupFileName: string, 617: ): Promise<void> { 618: const backupPath = resolveBackupPath(backupFileName) 619: let backupStats: Stats 620: try { 621: backupStats = await stat(backupPath) 622: } catch (e: unknown) { 623: if (isENOENT(e)) { 624: logEvent('tengu_file_history_rewind_restore_file_failed', {}) 625: logError( 626: new Error(`FileHistory: [Rewind] Backup file not found: ${backupPath}`), 627: ) 628: return 629: } 630: throw e 631: } 632: try { 633: await copyFile(backupPath, filePath) 634: } catch (e: unknown) { 635: if (!isENOENT(e)) throw e 636: await mkdir(dirname(filePath), { recursive: true }) 637: await copyFile(backupPath, filePath) 638: } 639: await chmod(filePath, backupStats.mode) 640: } 641: function getBackupFileNameFirstVersion( 642: trackingPath: string, 643: state: FileHistoryState, 644: ): BackupFileName | undefined { 645: for (const snapshot of state.snapshots) { 646: const backup = snapshot.trackedFileBackups[trackingPath] 647: if (backup !== undefined && backup.version === 1) { 648: return backup.backupFileName 649: } 650: } 651: return undefined 652: } 653: function maybeShortenFilePath(filePath: string): string { 654: if (!isAbsolute(filePath)) { 655: return filePath 656: } 657: const cwd = getOriginalCwd() 658: if (filePath.startsWith(cwd)) { 659: return relative(cwd, filePath) 660: } 661: return filePath 662: } 663: function maybeExpandFilePath(filePath: string): string { 664: if (isAbsolute(filePath)) { 665: return filePath 666: } 667: return join(getOriginalCwd(), filePath) 668: } 669: export function fileHistoryRestoreStateFromLog( 670: fileHistorySnapshots: FileHistorySnapshot[], 671: onUpdateState: (newState: FileHistoryState) => void, 672: ): void { 673: if (!fileHistoryEnabled()) { 674: return 675: } 676: const snapshots: FileHistorySnapshot[] = [] 677: const trackedFiles = new Set<string>() 678: for (const snapshot of fileHistorySnapshots) { 679: const trackedFileBackups: Record<string, FileHistoryBackup> = {} 680: for (const [path, backup] of Object.entries(snapshot.trackedFileBackups)) { 681: const trackingPath = maybeShortenFilePath(path) 682: trackedFiles.add(trackingPath) 683: trackedFileBackups[trackingPath] = backup 684: } 685: snapshots.push({ 686: ...snapshot, 687: trackedFileBackups: trackedFileBackups, 688: }) 689: } 690: onUpdateState({ 691: snapshots: snapshots, 692: trackedFiles: trackedFiles, 693: snapshotSequence: snapshots.length, 694: }) 695: } 696: export async function copyFileHistoryForResume(log: LogOption): Promise<void> { 697: if (!fileHistoryEnabled()) { 698: return 699: } 700: const fileHistorySnapshots = log.fileHistorySnapshots 701: if (!fileHistorySnapshots || log.messages.length === 0) { 702: return 703: } 704: const lastMessage = log.messages[log.messages.length - 1] 705: const previousSessionId = lastMessage?.sessionId 706: if (!previousSessionId) { 707: logError( 708: new Error( 709: `FileHistory: Failed to copy backups on restore (no previous session id)`, 710: ), 711: ) 712: return 713: } 714: const sessionId = getSessionId() 715: if (previousSessionId === sessionId) { 716: logForDebugging( 717: `FileHistory: No need to copy file history for resuming with same session id: ${sessionId}`, 718: ) 719: return 720: } 721: try { 722: const newBackupDir = join( 723: getClaudeConfigHomeDir(), 724: 'file-history', 725: sessionId, 726: ) 727: await mkdir(newBackupDir, { recursive: true }) 728: let failedSnapshots = 0 729: await Promise.allSettled( 730: fileHistorySnapshots.map(async snapshot => { 731: const backupEntries = Object.values(snapshot.trackedFileBackups).filter( 732: (backup): backup is typeof backup & { backupFileName: string } => 733: backup.backupFileName !== null, 734: ) 735: const results = await Promise.allSettled( 736: backupEntries.map(async ({ backupFileName }) => { 737: const oldBackupPath = resolveBackupPath( 738: backupFileName, 739: previousSessionId, 740: ) 741: const newBackupPath = join(newBackupDir, backupFileName) 742: try { 743: await link(oldBackupPath, newBackupPath) 744: } catch (e: unknown) { 745: const code = getErrnoCode(e) 746: if (code === 'EEXIST') { 747: return 748: } 749: if (code === 'ENOENT') { 750: logError( 751: new Error( 752: `FileHistory: Failed to copy backup ${backupFileName} on restore (backup file does not exist in ${previousSessionId})`, 753: ), 754: ) 755: throw e 756: } 757: logError( 758: new Error( 759: `FileHistory: Error hard linking backup file from previous session`, 760: ), 761: ) 762: try { 763: await copyFile(oldBackupPath, newBackupPath) 764: } catch (copyErr) { 765: logError( 766: new Error( 767: `FileHistory: Error copying over backup from previous session`, 768: ), 769: ) 770: throw copyErr 771: } 772: } 773: logForDebugging( 774: `FileHistory: Copied backup ${backupFileName} from session ${previousSessionId} to ${sessionId}`, 775: ) 776: }), 777: ) 778: const copyFailed = results.some(r => r.status === 'rejected') 779: if (!copyFailed) { 780: void recordFileHistorySnapshot( 781: snapshot.messageId, 782: snapshot, 783: false, 784: ).catch(_ => { 785: logError( 786: new Error(`FileHistory: Failed to record copy backup snapshot`), 787: ) 788: }) 789: } else { 790: failedSnapshots++ 791: } 792: }), 793: ) 794: if (failedSnapshots > 0) { 795: logEvent('tengu_file_history_resume_copy_failed', { 796: numSnapshots: fileHistorySnapshots.length, 797: failedSnapshots, 798: }) 799: } 800: } catch (error) { 801: logError(error) 802: } 803: } 804: async function notifyVscodeSnapshotFilesUpdated( 805: oldState: FileHistoryState, 806: newState: FileHistoryState, 807: ): Promise<void> { 808: const oldSnapshot = oldState.snapshots.at(-1) 809: const newSnapshot = newState.snapshots.at(-1) 810: if (!newSnapshot) { 811: return 812: } 813: for (const trackingPath of newState.trackedFiles) { 814: const filePath = maybeExpandFilePath(trackingPath) 815: const oldBackup = oldSnapshot?.trackedFileBackups[trackingPath] 816: const newBackup = newSnapshot.trackedFileBackups[trackingPath] 817: if ( 818: oldBackup?.backupFileName === newBackup?.backupFileName && 819: oldBackup?.version === newBackup?.version 820: ) { 821: continue 822: } 823: let oldContent: string | null = null 824: if (oldBackup?.backupFileName) { 825: const backupPath = resolveBackupPath(oldBackup.backupFileName) 826: oldContent = await readFileAsyncOrNull(backupPath) 827: } 828: let newContent: string | null = null 829: if (newBackup?.backupFileName) { 830: const backupPath = resolveBackupPath(newBackup.backupFileName) 831: newContent = await readFileAsyncOrNull(backupPath) 832: } 833: if (oldContent !== newContent) { 834: notifyVscodeFileUpdated(filePath, oldContent, newContent) 835: } 836: } 837: } 838: async function readFileAsyncOrNull(path: string): Promise<string | null> { 839: try { 840: return await readFile(path, 'utf-8') 841: } catch { 842: return null 843: } 844: } 845: const ENABLE_DUMP_STATE = false 846: function maybeDumpStateForDebug(state: FileHistoryState): void { 847: if (ENABLE_DUMP_STATE) { 848: console.error(inspect(state, false, 5)) 849: } 850: }

File: src/utils/fileOperationAnalytics.ts

typescript 1: import { createHash } from 'crypto' 2: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/index.js' 3: import { logEvent } from 'src/services/analytics/index.js' 4: function hashFilePath( 5: filePath: string, 6: ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { 7: return createHash('sha256') 8: .update(filePath) 9: .digest('hex') 10: .slice(0, 16) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 11: } 12: function hashFileContent( 13: content: string, 14: ): AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS { 15: return createHash('sha256') 16: .update(content) 17: .digest('hex') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 18: } 19: const MAX_CONTENT_HASH_SIZE = 100 * 1024 20: export function logFileOperation(params: { 21: operation: 'read' | 'write' | 'edit' 22: tool: 'FileReadTool' | 'FileWriteTool' | 'FileEditTool' 23: filePath: string 24: content?: string 25: type?: 'create' | 'update' 26: }): void { 27: const metadata: Record< 28: string, 29: | AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 30: | number 31: | boolean 32: > = { 33: operation: 34: params.operation as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 35: tool: params.tool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 36: filePathHash: hashFilePath(params.filePath), 37: } 38: if ( 39: params.content !== undefined && 40: params.content.length <= MAX_CONTENT_HASH_SIZE 41: ) { 42: metadata.contentHash = hashFileContent(params.content) 43: } 44: if (params.type !== undefined) { 45: metadata.type = 46: params.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS 47: } 48: logEvent('tengu_file_operation', metadata) 49: }

File: src/utils/fileRead.ts

typescript 1: import { logForDebugging } from './debug.js' 2: import { getFsImplementation, safeResolvePath } from './fsOperations.js' 3: export type LineEndingType = 'CRLF' | 'LF' 4: export function detectEncodingForResolvedPath( 5: resolvedPath: string, 6: ): BufferEncoding { 7: const { buffer, bytesRead } = getFsImplementation().readSync(resolvedPath, { 8: length: 4096, 9: }) 10: if (bytesRead === 0) { 11: return 'utf8' 12: } 13: if (bytesRead >= 2) { 14: if (buffer[0] === 0xff && buffer[1] === 0xfe) return 'utf16le' 15: } 16: if ( 17: bytesRead >= 3 && 18: buffer[0] === 0xef && 19: buffer[1] === 0xbb && 20: buffer[2] === 0xbf 21: ) { 22: return 'utf8' 23: } 24: return 'utf8' 25: } 26: export function detectLineEndingsForString(content: string): LineEndingType { 27: let crlfCount = 0 28: let lfCount = 0 29: for (let i = 0; i < content.length; i++) { 30: if (content[i] === '\n') { 31: if (i > 0 && content[i - 1] === '\r') { 32: crlfCount++ 33: } else { 34: lfCount++ 35: } 36: } 37: } 38: return crlfCount > lfCount ? 'CRLF' : 'LF' 39: } 40: export function readFileSyncWithMetadata(filePath: string): { 41: content: string 42: encoding: BufferEncoding 43: lineEndings: LineEndingType 44: } { 45: const fs = getFsImplementation() 46: const { resolvedPath, isSymlink } = safeResolvePath(fs, filePath) 47: if (isSymlink) { 48: logForDebugging(`Reading through symlink: ${filePath} -> ${resolvedPath}`) 49: } 50: const encoding = detectEncodingForResolvedPath(resolvedPath) 51: const raw = fs.readFileSync(resolvedPath, { encoding }) 52: const lineEndings = detectLineEndingsForString(raw.slice(0, 4096)) 53: return { 54: content: raw.replaceAll('\r\n', '\n'), 55: encoding, 56: lineEndings, 57: } 58: } 59: export function readFileSync(filePath: string): string { 60: return readFileSyncWithMetadata(filePath).content 61: }

File: src/utils/fileReadCache.ts

typescript 1: import { detectFileEncoding } from './file.js' 2: import { getFsImplementation } from './fsOperations.js' 3: type CachedFileData = { 4: content: string 5: encoding: BufferEncoding 6: mtime: number 7: } 8: class FileReadCache { 9: private cache = new Map<string, CachedFileData>() 10: private readonly maxCacheSize = 1000 11: readFile(filePath: string): { content: string; encoding: BufferEncoding } { 12: const fs = getFsImplementation() 13: let stats 14: try { 15: stats = fs.statSync(filePath) 16: } catch (error) { 17: this.cache.delete(filePath) 18: throw error 19: } 20: const cacheKey = filePath 21: const cachedData = this.cache.get(cacheKey) 22: if (cachedData && cachedData.mtime === stats.mtimeMs) { 23: return { 24: content: cachedData.content, 25: encoding: cachedData.encoding, 26: } 27: } 28: const encoding = detectFileEncoding(filePath) 29: const content = fs 30: .readFileSync(filePath, { encoding }) 31: .replaceAll('\r\n', '\n') 32: this.cache.set(cacheKey, { 33: content, 34: encoding, 35: mtime: stats.mtimeMs, 36: }) 37: if (this.cache.size > this.maxCacheSize) { 38: const firstKey = this.cache.keys().next().value 39: if (firstKey) { 40: this.cache.delete(firstKey) 41: } 42: } 43: return { content, encoding } 44: } 45: clear(): void { 46: this.cache.clear() 47: } 48: invalidate(filePath: string): void { 49: this.cache.delete(filePath) 50: } 51: getStats(): { size: number; entries: string[] } { 52: return { 53: size: this.cache.size, 54: entries: Array.from(this.cache.keys()), 55: } 56: } 57: } 58: export const fileReadCache = new FileReadCache()

File: src/utils/fileStateCache.ts

typescript 1: import { LRUCache } from 'lru-cache' 2: import { normalize } from 'path' 3: export type FileState = { 4: content: string 5: timestamp: number 6: offset: number | undefined 7: limit: number | undefined 8: isPartialView?: boolean 9: } 10: export const READ_FILE_STATE_CACHE_SIZE = 100 11: const DEFAULT_MAX_CACHE_SIZE_BYTES = 25 * 1024 * 1024 12: export class FileStateCache { 13: private cache: LRUCache<string, FileState> 14: constructor(maxEntries: number, maxSizeBytes: number) { 15: this.cache = new LRUCache<string, FileState>({ 16: max: maxEntries, 17: maxSize: maxSizeBytes, 18: sizeCalculation: value => Math.max(1, Buffer.byteLength(value.content)), 19: }) 20: } 21: get(key: string): FileState | undefined { 22: return this.cache.get(normalize(key)) 23: } 24: set(key: string, value: FileState): this { 25: this.cache.set(normalize(key), value) 26: return this 27: } 28: has(key: string): boolean { 29: return this.cache.has(normalize(key)) 30: } 31: delete(key: string): boolean { 32: return this.cache.delete(normalize(key)) 33: } 34: clear(): void { 35: this.cache.clear() 36: } 37: get size(): number { 38: return this.cache.size 39: } 40: get max(): number { 41: return this.cache.max 42: } 43: get maxSize(): number { 44: return this.cache.maxSize 45: } 46: get calculatedSize(): number { 47: return this.cache.calculatedSize 48: } 49: keys(): Generator<string> { 50: return this.cache.keys() 51: } 52: entries(): Generator<[string, FileState]> { 53: return this.cache.entries() 54: } 55: dump(): ReturnType<LRUCache<string, FileState>['dump']> { 56: return this.cache.dump() 57: } 58: load(entries: ReturnType<LRUCache<string, FileState>['dump']>): void { 59: this.cache.load(entries) 60: } 61: } 62: export function createFileStateCacheWithSizeLimit( 63: maxEntries: number, 64: maxSizeBytes: number = DEFAULT_MAX_CACHE_SIZE_BYTES, 65: ): FileStateCache { 66: return new FileStateCache(maxEntries, maxSizeBytes) 67: } 68: export function cacheToObject( 69: cache: FileStateCache, 70: ): Record<string, FileState> { 71: return Object.fromEntries(cache.entries()) 72: } 73: export function cacheKeys(cache: FileStateCache): string[] { 74: return Array.from(cache.keys()) 75: } 76: export function cloneFileStateCache(cache: FileStateCache): FileStateCache { 77: const cloned = createFileStateCacheWithSizeLimit(cache.max, cache.maxSize) 78: cloned.load(cache.dump()) 79: return cloned 80: } 81: export function mergeFileStateCaches( 82: first: FileStateCache, 83: second: FileStateCache, 84: ): FileStateCache { 85: const merged = cloneFileStateCache(first) 86: for (const [filePath, fileState] of second.entries()) { 87: const existing = merged.get(filePath) 88: if (!existing || fileState.timestamp > existing.timestamp) { 89: merged.set(filePath, fileState) 90: } 91: } 92: return merged 93: }

File: src/utils/findExecutable.ts

typescript 1: import { whichSync } from './which.js' 2: export function findExecutable( 3: exe: string, 4: args: string[], 5: ): { cmd: string; args: string[] } { 6: const resolved = whichSync(exe) 7: return { cmd: resolved ?? exe, args } 8: }

File: src/utils/fingerprint.ts

typescript 1: import { createHash } from 'crypto' 2: import type { AssistantMessage, UserMessage } from '../types/message.js' 3: export const FINGERPRINT_SALT = '59cf53e54c78' 4: export function extractFirstMessageText( 5: messages: (UserMessage | AssistantMessage)[], 6: ): string { 7: const firstUserMessage = messages.find(msg => msg.type === 'user') 8: if (!firstUserMessage) { 9: return '' 10: } 11: const content = firstUserMessage.message.content 12: if (typeof content === 'string') { 13: return content 14: } 15: if (Array.isArray(content)) { 16: const textBlock = content.find(block => block.type === 'text') 17: if (textBlock && textBlock.type === 'text') { 18: return textBlock.text 19: } 20: } 21: return '' 22: } 23: /** 24: * Computes 3-character fingerprint for Claude Code attribution. 25: * Algorithm: SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3] 26: * IMPORTANT: Do not change this method without careful coordination with 27: * 1P and 3P (Bedrock, Vertex, Azure) APIs. 28: * 29: * @param messageText - First user message text content 30: * @param version - Version string (from MACRO.VERSION) 31: * @returns 3-character hex fingerprint 32: */ 33: export function computeFingerprint( 34: messageText: string, 35: version: string, 36: ): string { 37: // Extract chars at indices [4, 7, 20], use "0" if index not found 38: const indices = [4, 7, 20] 39: const chars = indices.map(i => messageText[i] || '0').join('') 40: const fingerprintInput = `${FINGERPRINT_SALT}${chars}${version}` 41: // SHA256 hash, return first 3 hex chars 42: const hash = createHash('sha256').update(fingerprintInput).digest('hex') 43: return hash.slice(0, 3) 44: } 45: export function computeFingerprintFromMessages( 46: messages: (UserMessage | AssistantMessage)[], 47: ): string { 48: const firstMessageText = extractFirstMessageText(messages) 49: return computeFingerprint(firstMessageText, MACRO.VERSION) 50: }

File: src/utils/forkedAgent.ts

typescript 1: import type { UUID } from 'crypto' 2: import { randomUUID } from 'crypto' 3: import type { PromptCommand } from '../commands.js' 4: import type { QuerySource } from '../constants/querySource.js' 5: import type { CanUseToolFn } from '../hooks/useCanUseTool.js' 6: import { query } from '../query.js' 7: import { 8: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 9: logEvent, 10: } from '../services/analytics/index.js' 11: import { accumulateUsage, updateUsage } from '../services/api/claude.js' 12: import { EMPTY_USAGE, type NonNullableUsage } from '../services/api/logging.js' 13: import type { ToolUseContext } from '../Tool.js' 14: import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' 15: import type { AgentId } from '../types/ids.js' 16: import type { Message } from '../types/message.js' 17: import { createChildAbortController } from './abortController.js' 18: import { logForDebugging } from './debug.js' 19: import { cloneFileStateCache } from './fileStateCache.js' 20: import type { REPLHookContext } from './hooks/postSamplingHooks.js' 21: import { 22: createUserMessage, 23: extractTextContent, 24: getLastAssistantMessage, 25: } from './messages.js' 26: import { createDenialTrackingState } from './permissions/denialTracking.js' 27: import { parseToolListFromCLI } from './permissions/permissionSetup.js' 28: import { recordSidechainTranscript } from './sessionStorage.js' 29: import type { SystemPrompt } from './systemPromptType.js' 30: import { 31: type ContentReplacementState, 32: cloneContentReplacementState, 33: } from './toolResultStorage.js' 34: import { createAgentId } from './uuid.js' 35: export type CacheSafeParams = { 36: systemPrompt: SystemPrompt 37: userContext: { [k: string]: string } 38: systemContext: { [k: string]: string } 39: toolUseContext: ToolUseContext 40: forkContextMessages: Message[] 41: } 42: let lastCacheSafeParams: CacheSafeParams | null = null 43: export function saveCacheSafeParams(params: CacheSafeParams | null): void { 44: lastCacheSafeParams = params 45: } 46: export function getLastCacheSafeParams(): CacheSafeParams | null { 47: return lastCacheSafeParams 48: } 49: export type ForkedAgentParams = { 50: promptMessages: Message[] 51: cacheSafeParams: CacheSafeParams 52: canUseTool: CanUseToolFn 53: querySource: QuerySource 54: forkLabel: string 55: overrides?: SubagentContextOverrides 56: maxOutputTokens?: number 57: maxTurns?: number 58: onMessage?: (message: Message) => void 59: skipTranscript?: boolean 60: skipCacheWrite?: boolean 61: } 62: export type ForkedAgentResult = { 63: messages: Message[] 64: totalUsage: NonNullableUsage 65: } 66: export function createCacheSafeParams( 67: context: REPLHookContext, 68: ): CacheSafeParams { 69: return { 70: systemPrompt: context.systemPrompt, 71: userContext: context.userContext, 72: systemContext: context.systemContext, 73: toolUseContext: context.toolUseContext, 74: forkContextMessages: context.messages, 75: } 76: } 77: export function createGetAppStateWithAllowedTools( 78: baseGetAppState: ToolUseContext['getAppState'], 79: allowedTools: string[], 80: ): ToolUseContext['getAppState'] { 81: if (allowedTools.length === 0) return baseGetAppState 82: return () => { 83: const appState = baseGetAppState() 84: return { 85: ...appState, 86: toolPermissionContext: { 87: ...appState.toolPermissionContext, 88: alwaysAllowRules: { 89: ...appState.toolPermissionContext.alwaysAllowRules, 90: command: [ 91: ...new Set([ 92: ...(appState.toolPermissionContext.alwaysAllowRules.command || 93: []), 94: ...allowedTools, 95: ]), 96: ], 97: }, 98: }, 99: } 100: } 101: } 102: export type PreparedForkedContext = { 103: skillContent: string 104: modifiedGetAppState: ToolUseContext['getAppState'] 105: baseAgent: AgentDefinition 106: promptMessages: Message[] 107: } 108: export async function prepareForkedCommandContext( 109: command: PromptCommand, 110: args: string, 111: context: ToolUseContext, 112: ): Promise<PreparedForkedContext> { 113: const skillPrompt = await command.getPromptForCommand(args, context) 114: const skillContent = skillPrompt 115: .map(block => (block.type === 'text' ? block.text : '')) 116: .join('\n') 117: const allowedTools = parseToolListFromCLI(command.allowedTools ?? []) 118: const modifiedGetAppState = createGetAppStateWithAllowedTools( 119: context.getAppState, 120: allowedTools, 121: ) 122: const agentTypeName = command.agent ?? 'general-purpose' 123: const agents = context.options.agentDefinitions.activeAgents 124: const baseAgent = 125: agents.find(a => a.agentType === agentTypeName) ?? 126: agents.find(a => a.agentType === 'general-purpose') ?? 127: agents[0] 128: if (!baseAgent) { 129: throw new Error('No agent available for forked execution') 130: } 131: const promptMessages = [createUserMessage({ content: skillContent })] 132: return { 133: skillContent, 134: modifiedGetAppState, 135: baseAgent, 136: promptMessages, 137: } 138: } 139: export function extractResultText( 140: agentMessages: Message[], 141: defaultText = 'Execution completed', 142: ): string { 143: const lastAssistantMessage = getLastAssistantMessage(agentMessages) 144: if (!lastAssistantMessage) return defaultText 145: const textContent = extractTextContent( 146: lastAssistantMessage.message.content, 147: '\n', 148: ) 149: return textContent || defaultText 150: } 151: export type SubagentContextOverrides = { 152: options?: ToolUseContext['options'] 153: agentId?: AgentId 154: agentType?: string 155: messages?: Message[] 156: readFileState?: ToolUseContext['readFileState'] 157: abortController?: AbortController 158: getAppState?: ToolUseContext['getAppState'] 159: shareSetAppState?: boolean 160: shareSetResponseLength?: boolean 161: shareAbortController?: boolean 162: criticalSystemReminder_EXPERIMENTAL?: string 163: requireCanUseTool?: boolean 164: contentReplacementState?: ContentReplacementState 165: } 166: export function createSubagentContext( 167: parentContext: ToolUseContext, 168: overrides?: SubagentContextOverrides, 169: ): ToolUseContext { 170: const abortController = 171: overrides?.abortController ?? 172: (overrides?.shareAbortController 173: ? parentContext.abortController 174: : createChildAbortController(parentContext.abortController)) 175: const getAppState: ToolUseContext['getAppState'] = overrides?.getAppState 176: ? overrides.getAppState 177: : overrides?.shareAbortController 178: ? parentContext.getAppState 179: : () => { 180: const state = parentContext.getAppState() 181: if (state.toolPermissionContext.shouldAvoidPermissionPrompts) { 182: return state 183: } 184: return { 185: ...state, 186: toolPermissionContext: { 187: ...state.toolPermissionContext, 188: shouldAvoidPermissionPrompts: true, 189: }, 190: } 191: } 192: return { 193: readFileState: cloneFileStateCache( 194: overrides?.readFileState ?? parentContext.readFileState, 195: ), 196: nestedMemoryAttachmentTriggers: new Set<string>(), 197: loadedNestedMemoryPaths: new Set<string>(), 198: dynamicSkillDirTriggers: new Set<string>(), 199: discoveredSkillNames: new Set<string>(), 200: toolDecisions: undefined, 201: contentReplacementState: 202: overrides?.contentReplacementState ?? 203: (parentContext.contentReplacementState 204: ? cloneContentReplacementState(parentContext.contentReplacementState) 205: : undefined), 206: abortController, 207: getAppState, 208: setAppState: overrides?.shareSetAppState 209: ? parentContext.setAppState 210: : () => {}, 211: setAppStateForTasks: 212: parentContext.setAppStateForTasks ?? parentContext.setAppState, 213: localDenialTracking: overrides?.shareSetAppState 214: ? parentContext.localDenialTracking 215: : createDenialTrackingState(), 216: setInProgressToolUseIDs: () => {}, 217: setResponseLength: overrides?.shareSetResponseLength 218: ? parentContext.setResponseLength 219: : () => {}, 220: pushApiMetricsEntry: overrides?.shareSetResponseLength 221: ? parentContext.pushApiMetricsEntry 222: : undefined, 223: updateFileHistoryState: () => {}, 224: updateAttributionState: parentContext.updateAttributionState, 225: addNotification: undefined, 226: setToolJSX: undefined, 227: setStreamMode: undefined, 228: setSDKStatus: undefined, 229: openMessageSelector: undefined, 230: options: overrides?.options ?? parentContext.options, 231: messages: overrides?.messages ?? parentContext.messages, 232: agentId: overrides?.agentId ?? createAgentId(), 233: agentType: overrides?.agentType, 234: queryTracking: { 235: chainId: randomUUID(), 236: depth: (parentContext.queryTracking?.depth ?? -1) + 1, 237: }, 238: fileReadingLimits: parentContext.fileReadingLimits, 239: userModified: parentContext.userModified, 240: criticalSystemReminder_EXPERIMENTAL: 241: overrides?.criticalSystemReminder_EXPERIMENTAL, 242: requireCanUseTool: overrides?.requireCanUseTool, 243: } 244: } 245: export async function runForkedAgent({ 246: promptMessages, 247: cacheSafeParams, 248: canUseTool, 249: querySource, 250: forkLabel, 251: overrides, 252: maxOutputTokens, 253: maxTurns, 254: onMessage, 255: skipTranscript, 256: skipCacheWrite, 257: }: ForkedAgentParams): Promise<ForkedAgentResult> { 258: const startTime = Date.now() 259: const outputMessages: Message[] = [] 260: let totalUsage: NonNullableUsage = { ...EMPTY_USAGE } 261: const { 262: systemPrompt, 263: userContext, 264: systemContext, 265: toolUseContext, 266: forkContextMessages, 267: } = cacheSafeParams 268: const isolatedToolUseContext = createSubagentContext( 269: toolUseContext, 270: overrides, 271: ) 272: const initialMessages: Message[] = [...forkContextMessages, ...promptMessages] 273: const agentId = skipTranscript ? undefined : createAgentId(forkLabel) 274: let lastRecordedUuid: UUID | null = null 275: if (agentId) { 276: await recordSidechainTranscript(initialMessages, agentId).catch(err => 277: logForDebugging( 278: `Forked agent [${forkLabel}] failed to record initial transcript: ${err}`, 279: ), 280: ) 281: lastRecordedUuid = 282: initialMessages.length > 0 283: ? initialMessages[initialMessages.length - 1]!.uuid 284: : null 285: } 286: try { 287: for await (const message of query({ 288: messages: initialMessages, 289: systemPrompt, 290: userContext, 291: systemContext, 292: canUseTool, 293: toolUseContext: isolatedToolUseContext, 294: querySource, 295: maxOutputTokensOverride: maxOutputTokens, 296: maxTurns, 297: skipCacheWrite, 298: })) { 299: if (message.type === 'stream_event') { 300: if ( 301: 'event' in message && 302: message.event?.type === 'message_delta' && 303: message.event.usage 304: ) { 305: const turnUsage = updateUsage({ ...EMPTY_USAGE }, message.event.usage) 306: totalUsage = accumulateUsage(totalUsage, turnUsage) 307: } 308: continue 309: } 310: if (message.type === 'stream_request_start') { 311: continue 312: } 313: logForDebugging( 314: `Forked agent [${forkLabel}] received message: type=${message.type}`, 315: ) 316: outputMessages.push(message as Message) 317: onMessage?.(message as Message) 318: const msg = message as Message 319: if ( 320: agentId && 321: (msg.type === 'assistant' || 322: msg.type === 'user' || 323: msg.type === 'progress') 324: ) { 325: await recordSidechainTranscript([msg], agentId, lastRecordedUuid).catch( 326: err => 327: logForDebugging( 328: `Forked agent [${forkLabel}] failed to record transcript: ${err}`, 329: ), 330: ) 331: if (msg.type !== 'progress') { 332: lastRecordedUuid = msg.uuid 333: } 334: } 335: } 336: } finally { 337: isolatedToolUseContext.readFileState.clear() 338: initialMessages.length = 0 339: } 340: logForDebugging( 341: `Forked agent [${forkLabel}] finished: ${outputMessages.length} messages, types=[${outputMessages.map(m => m.type).join(', ')}], totalUsage: input=${totalUsage.input_tokens} output=${totalUsage.output_tokens} cacheRead=${totalUsage.cache_read_input_tokens} cacheCreate=${totalUsage.cache_creation_input_tokens}`, 342: ) 343: const durationMs = Date.now() - startTime 344: logForkAgentQueryEvent({ 345: forkLabel, 346: querySource, 347: durationMs, 348: messageCount: outputMessages.length, 349: totalUsage, 350: queryTracking: toolUseContext.queryTracking, 351: }) 352: return { 353: messages: outputMessages, 354: totalUsage, 355: } 356: } 357: function logForkAgentQueryEvent({ 358: forkLabel, 359: querySource, 360: durationMs, 361: messageCount, 362: totalUsage, 363: queryTracking, 364: }: { 365: forkLabel: string 366: querySource: QuerySource 367: durationMs: number 368: messageCount: number 369: totalUsage: NonNullableUsage 370: queryTracking?: { chainId: string; depth: number } 371: }): void { 372: const totalInputTokens = 373: totalUsage.input_tokens + 374: totalUsage.cache_creation_input_tokens + 375: totalUsage.cache_read_input_tokens 376: const cacheHitRate = 377: totalInputTokens > 0 378: ? totalUsage.cache_read_input_tokens / totalInputTokens 379: : 0 380: logEvent('tengu_fork_agent_query', { 381: forkLabel: 382: forkLabel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 383: querySource: 384: querySource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 385: durationMs, 386: messageCount, 387: inputTokens: totalUsage.input_tokens, 388: outputTokens: totalUsage.output_tokens, 389: cacheReadInputTokens: totalUsage.cache_read_input_tokens, 390: cacheCreationInputTokens: totalUsage.cache_creation_input_tokens, 391: serviceTier: 392: totalUsage.service_tier as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 393: cacheCreationEphemeral1hTokens: 394: totalUsage.cache_creation.ephemeral_1h_input_tokens, 395: cacheCreationEphemeral5mTokens: 396: totalUsage.cache_creation.ephemeral_5m_input_tokens, 397: cacheHitRate, 398: ...(queryTracking 399: ? { 400: queryChainId: 401: queryTracking.chainId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 402: queryDepth: queryTracking.depth, 403: } 404: : {}), 405: }) 406: }

File: src/utils/format.ts

typescript 1: import { getRelativeTimeFormat, getTimeZone } from './intl.js' 2: export function formatFileSize(sizeInBytes: number): string { 3: const kb = sizeInBytes / 1024 4: if (kb < 1) { 5: return `${sizeInBytes} bytes` 6: } 7: if (kb < 1024) { 8: return `${kb.toFixed(1).replace(/\.0$/, '')}KB` 9: } 10: const mb = kb / 1024 11: if (mb < 1024) { 12: return `${mb.toFixed(1).replace(/\.0$/, '')}MB` 13: } 14: const gb = mb / 1024 15: return `${gb.toFixed(1).replace(/\.0$/, '')}GB` 16: } 17: export function formatSecondsShort(ms: number): string { 18: return `${(ms / 1000).toFixed(1)}s` 19: } 20: export function formatDuration( 21: ms: number, 22: options?: { hideTrailingZeros?: boolean; mostSignificantOnly?: boolean }, 23: ): string { 24: if (ms < 60000) { 25: if (ms === 0) { 26: return '0s' 27: } 28: if (ms < 1) { 29: const s = (ms / 1000).toFixed(1) 30: return `${s}s` 31: } 32: const s = Math.floor(ms / 1000).toString() 33: return `${s}s` 34: } 35: let days = Math.floor(ms / 86400000) 36: let hours = Math.floor((ms % 86400000) / 3600000) 37: let minutes = Math.floor((ms % 3600000) / 60000) 38: let seconds = Math.round((ms % 60000) / 1000) 39: if (seconds === 60) { 40: seconds = 0 41: minutes++ 42: } 43: if (minutes === 60) { 44: minutes = 0 45: hours++ 46: } 47: if (hours === 24) { 48: hours = 0 49: days++ 50: } 51: const hide = options?.hideTrailingZeros 52: if (options?.mostSignificantOnly) { 53: if (days > 0) return `${days}d` 54: if (hours > 0) return `${hours}h` 55: if (minutes > 0) return `${minutes}m` 56: return `${seconds}s` 57: } 58: if (days > 0) { 59: if (hide && hours === 0 && minutes === 0) return `${days}d` 60: if (hide && minutes === 0) return `${days}d ${hours}h` 61: return `${days}d ${hours}h ${minutes}m` 62: } 63: if (hours > 0) { 64: if (hide && minutes === 0 && seconds === 0) return `${hours}h` 65: if (hide && seconds === 0) return `${hours}h ${minutes}m` 66: return `${hours}h ${minutes}m ${seconds}s` 67: } 68: if (minutes > 0) { 69: if (hide && seconds === 0) return `${minutes}m` 70: return `${minutes}m ${seconds}s` 71: } 72: return `${seconds}s` 73: } 74: let numberFormatterForConsistentDecimals: Intl.NumberFormat | null = null 75: let numberFormatterForInconsistentDecimals: Intl.NumberFormat | null = null 76: const getNumberFormatter = ( 77: useConsistentDecimals: boolean, 78: ): Intl.NumberFormat => { 79: if (useConsistentDecimals) { 80: if (!numberFormatterForConsistentDecimals) { 81: numberFormatterForConsistentDecimals = new Intl.NumberFormat('en-US', { 82: notation: 'compact', 83: maximumFractionDigits: 1, 84: minimumFractionDigits: 1, 85: }) 86: } 87: return numberFormatterForConsistentDecimals 88: } else { 89: if (!numberFormatterForInconsistentDecimals) { 90: numberFormatterForInconsistentDecimals = new Intl.NumberFormat('en-US', { 91: notation: 'compact', 92: maximumFractionDigits: 1, 93: minimumFractionDigits: 0, 94: }) 95: } 96: return numberFormatterForInconsistentDecimals 97: } 98: } 99: export function formatNumber(number: number): string { 100: const shouldUseConsistentDecimals = number >= 1000 101: return getNumberFormatter(shouldUseConsistentDecimals) 102: .format(number) 103: .toLowerCase() 104: } 105: export function formatTokens(count: number): string { 106: return formatNumber(count).replace('.0', '') 107: } 108: type RelativeTimeStyle = 'long' | 'short' | 'narrow' 109: type RelativeTimeOptions = { 110: style?: RelativeTimeStyle 111: numeric?: 'always' | 'auto' 112: } 113: export function formatRelativeTime( 114: date: Date, 115: options: RelativeTimeOptions & { now?: Date } = {}, 116: ): string { 117: const { style = 'narrow', numeric = 'always', now = new Date() } = options 118: const diffInMs = date.getTime() - now.getTime() 119: const diffInSeconds = Math.trunc(diffInMs / 1000) 120: const intervals = [ 121: { unit: 'year', seconds: 31536000, shortUnit: 'y' }, 122: { unit: 'month', seconds: 2592000, shortUnit: 'mo' }, 123: { unit: 'week', seconds: 604800, shortUnit: 'w' }, 124: { unit: 'day', seconds: 86400, shortUnit: 'd' }, 125: { unit: 'hour', seconds: 3600, shortUnit: 'h' }, 126: { unit: 'minute', seconds: 60, shortUnit: 'm' }, 127: { unit: 'second', seconds: 1, shortUnit: 's' }, 128: ] as const 129: for (const { unit, seconds: intervalSeconds, shortUnit } of intervals) { 130: if (Math.abs(diffInSeconds) >= intervalSeconds) { 131: const value = Math.trunc(diffInSeconds / intervalSeconds) 132: if (style === 'narrow') { 133: return diffInSeconds < 0 134: ? `${Math.abs(value)}${shortUnit} ago` 135: : `in ${value}${shortUnit}` 136: } 137: return getRelativeTimeFormat('long', numeric).format(value, unit) 138: } 139: } 140: if (style === 'narrow') { 141: return diffInSeconds <= 0 ? '0s ago' : 'in 0s' 142: } 143: return getRelativeTimeFormat(style, numeric).format(0, 'second') 144: } 145: export function formatRelativeTimeAgo( 146: date: Date, 147: options: RelativeTimeOptions & { now?: Date } = {}, 148: ): string { 149: const { now = new Date(), ...restOptions } = options 150: if (date > now) { 151: return formatRelativeTime(date, { ...restOptions, now }) 152: } 153: return formatRelativeTime(date, { ...restOptions, numeric: 'always', now }) 154: } 155: export function formatLogMetadata(log: { 156: modified: Date 157: messageCount: number 158: fileSize?: number 159: gitBranch?: string 160: tag?: string 161: agentSetting?: string 162: prNumber?: number 163: prRepository?: string 164: }): string { 165: const sizeOrCount = 166: log.fileSize !== undefined 167: ? formatFileSize(log.fileSize) 168: : `${log.messageCount} messages` 169: const parts = [ 170: formatRelativeTimeAgo(log.modified, { style: 'short' }), 171: ...(log.gitBranch ? [log.gitBranch] : []), 172: sizeOrCount, 173: ] 174: if (log.tag) { 175: parts.push(`#${log.tag}`) 176: } 177: if (log.agentSetting) { 178: parts.push(`@${log.agentSetting}`) 179: } 180: if (log.prNumber) { 181: parts.push( 182: log.prRepository 183: ? `${log.prRepository}#${log.prNumber}` 184: : `#${log.prNumber}`, 185: ) 186: } 187: return parts.join(' · ') 188: } 189: export function formatResetTime( 190: timestampInSeconds: number | undefined, 191: showTimezone: boolean = false, 192: showTime: boolean = true, 193: ): string | undefined { 194: if (!timestampInSeconds) return undefined 195: const date = new Date(timestampInSeconds * 1000) 196: const now = new Date() 197: const minutes = date.getMinutes() 198: const hoursUntilReset = (date.getTime() - now.getTime()) / (1000 * 60 * 60) 199: if (hoursUntilReset > 24) { 200: const dateOptions: Intl.DateTimeFormatOptions = { 201: month: 'short', 202: day: 'numeric', 203: hour: showTime ? 'numeric' : undefined, 204: minute: !showTime || minutes === 0 ? undefined : '2-digit', 205: hour12: showTime ? true : undefined, 206: } 207: if (date.getFullYear() !== now.getFullYear()) { 208: dateOptions.year = 'numeric' 209: } 210: const dateString = date.toLocaleString('en-US', dateOptions) 211: return ( 212: dateString.replace(/ ([AP]M)/i, (_match, ampm) => ampm.toLowerCase()) + 213: (showTimezone ? ` (${getTimeZone()})` : '') 214: ) 215: } 216: // For resets within 24 hours, show just the time (existing behavior) 217: const timeString = date.toLocaleTimeString('en-US', { 218: hour: 'numeric', 219: minute: minutes === 0 ? undefined : '2-digit', 220: hour12: true, 221: }) 222: return ( 223: timeString.replace(/ ([AP]M)/i, (_match, ampm) => ampm.toLowerCase()) + 224: (showTimezone ? ` (${getTimeZone()})` : '') 225: ) 226: } 227: export function formatResetText( 228: resetsAt: string, 229: showTimezone: boolean = false, 230: showTime: boolean = true, 231: ): string { 232: const dt = new Date(resetsAt) 233: return `${formatResetTime(Math.floor(dt.getTime() / 1000), showTimezone, showTime)}` 234: } 235: // Back-compat: truncate helpers moved to ./truncate.ts (needs ink/stringWidth) 236: export { 237: truncate, 238: truncatePathMiddle, 239: truncateStartToWidth, 240: truncateToWidth, 241: truncateToWidthNoEllipsis, 242: wrapText, 243: } from './truncate.js'

File: src/utils/formatBriefTimestamp.ts

typescript 1: export function formatBriefTimestamp( 2: isoString: string, 3: now: Date = new Date(), 4: ): string { 5: const d = new Date(isoString) 6: if (Number.isNaN(d.getTime())) { 7: return '' 8: } 9: const locale = getLocale() 10: const dayDiff = startOfDay(now) - startOfDay(d) 11: const daysAgo = Math.round(dayDiff / 86_400_000) 12: if (daysAgo === 0) { 13: return d.toLocaleTimeString(locale, { 14: hour: 'numeric', 15: minute: '2-digit', 16: }) 17: } 18: if (daysAgo > 0 && daysAgo < 7) { 19: return d.toLocaleString(locale, { 20: weekday: 'long', 21: hour: 'numeric', 22: minute: '2-digit', 23: }) 24: } 25: return d.toLocaleString(locale, { 26: weekday: 'long', 27: month: 'short', 28: day: 'numeric', 29: hour: 'numeric', 30: minute: '2-digit', 31: }) 32: } 33: function getLocale(): string | undefined { 34: const raw = 35: process.env.LC_ALL || process.env.LC_TIME || process.env.LANG || '' 36: if (!raw || raw === 'C' || raw === 'POSIX') { 37: return undefined 38: } 39: const base = raw.split('.')[0]!.split('@')[0]! 40: if (!base) { 41: return undefined 42: } 43: const tag = base.replaceAll('_', '-') 44: try { 45: new Intl.DateTimeFormat(tag) 46: return tag 47: } catch { 48: return undefined 49: } 50: } 51: function startOfDay(d: Date): number { 52: return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() 53: }

File: src/utils/fpsTracker.ts

typescript 1: export type FpsMetrics = { 2: averageFps: number 3: low1PctFps: number 4: } 5: export class FpsTracker { 6: private frameDurations: number[] = [] 7: private firstRenderTime: number | undefined 8: private lastRenderTime: number | undefined 9: record(durationMs: number): void { 10: const now = performance.now() 11: if (this.firstRenderTime === undefined) { 12: this.firstRenderTime = now 13: } 14: this.lastRenderTime = now 15: this.frameDurations.push(durationMs) 16: } 17: getMetrics(): FpsMetrics | undefined { 18: if ( 19: this.frameDurations.length === 0 || 20: this.firstRenderTime === undefined || 21: this.lastRenderTime === undefined 22: ) { 23: return undefined 24: } 25: const totalTimeMs = this.lastRenderTime - this.firstRenderTime 26: if (totalTimeMs <= 0) { 27: return undefined 28: } 29: const totalFrames = this.frameDurations.length 30: const averageFps = totalFrames / (totalTimeMs / 1000) 31: const sorted = this.frameDurations.slice().sort((a, b) => b - a) 32: const p99Index = Math.max(0, Math.ceil(sorted.length * 0.01) - 1) 33: const p99FrameTimeMs = sorted[p99Index]! 34: const low1PctFps = p99FrameTimeMs > 0 ? 1000 / p99FrameTimeMs : 0 35: return { 36: averageFps: Math.round(averageFps * 100) / 100, 37: low1PctFps: Math.round(low1PctFps * 100) / 100, 38: } 39: } 40: }

File: src/utils/frontmatterParser.ts

typescript 1: import { logForDebugging } from './debug.js' 2: import type { HooksSettings } from './settings/types.js' 3: import { parseYaml } from './yaml.js' 4: export type FrontmatterData = { 5: 'allowed-tools'?: string | string[] | null 6: description?: string | null 7: type?: string | null 8: 'argument-hint'?: string | null 9: when_to_use?: string | null 10: version?: string | null 11: 'hide-from-slash-command-tool'?: string | null 12: model?: string | null 13: skills?: string | null 14: 'user-invocable'?: string | null 15: hooks?: HooksSettings | null 16: effort?: string | null 17: context?: 'inline' | 'fork' | null 18: agent?: string | null 19: paths?: string | string[] | null 20: shell?: string | null 21: [key: string]: unknown 22: } 23: export type ParsedMarkdown = { 24: frontmatter: FrontmatterData 25: content: string 26: } 27: const YAML_SPECIAL_CHARS = /[{}[\]*&#!|>%@`]|: / 28: /** 29: * Pre-processes frontmatter text to quote values that contain special YAML characters. 30: * This allows glob patterns like **\/*.{ts,tsx} to be parsed correctly. 31: */ 32: function quoteProblematicValues(frontmatterText: string): string { 33: const lines = frontmatterText.split('\n') 34: const result: string[] = [] 35: for (const line of lines) { 36: // Match simple key: value lines (not indented, not list items, not block scalars) 37: const match = line.match(/^([a-zA-Z_-]+):\s+(.+)$/) 38: if (match) { 39: const [, key, value] = match 40: if (!key || !value) { 41: result.push(line) 42: continue 43: } 44: // Skip if already quoted 45: if ( 46: (value.startsWith('"') && value.endsWith('"')) || 47: (value.startsWith("'") && value.endsWith("'")) 48: ) { 49: result.push(line) 50: continue 51: } 52: // Quote if contains special YAML characters 53: if (YAML_SPECIAL_CHARS.test(value)) { 54: // Use double quotes and escape any existing double quotes 55: const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"') 56: result.push(`${key}: "${escaped}"`) 57: continue 58: } 59: } 60: result.push(line) 61: } 62: return result.join('\n') 63: } 64: export const FRONTMATTER_REGEX = /^---\s*\n([\s\S]*?)---\s*\n?/ 65: /** 66: * Parses markdown content to extract frontmatter and content 67: * @param markdown The raw markdown content 68: * @returns Object containing parsed frontmatter and content without frontmatter 69: */ 70: export function parseFrontmatter( 71: markdown: string, 72: sourcePath?: string, 73: ): ParsedMarkdown { 74: const match = markdown.match(FRONTMATTER_REGEX) 75: if (!match) { 76: // No frontmatter found 77: return { 78: frontmatter: {}, 79: content: markdown, 80: } 81: } 82: const frontmatterText = match[1] || '' 83: const content = markdown.slice(match[0].length) 84: let frontmatter: FrontmatterData = {} 85: try { 86: const parsed = parseYaml(frontmatterText) as FrontmatterData | null 87: if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { 88: frontmatter = parsed 89: } 90: } catch { 91: // YAML parsing failed - try again after quoting problematic values 92: try { 93: const quotedText = quoteProblematicValues(frontmatterText) 94: const parsed = parseYaml(quotedText) as FrontmatterData | null 95: if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { 96: frontmatter = parsed 97: } 98: } catch (retryError) { 99: // Still failed - log for debugging so users can diagnose broken frontmatter 100: const location = sourcePath ? ` in ${sourcePath}` : '' 101: logForDebugging( 102: `Failed to parse YAML frontmatter${location}: ${retryError instanceof Error ? retryError.message : retryError}`, 103: { level: 'warn' }, 104: ) 105: } 106: } 107: return { 108: frontmatter, 109: content, 110: } 111: } 112: /** 113: * Splits a comma-separated string and expands brace patterns. 114: * Commas inside braces are not treated as separators. 115: * Also accepts a YAML list (string array) for ergonomic frontmatter. 116: * @param input - Comma-separated string, or array of strings, with optional brace patterns 117: * @returns Array of expanded strings 118: * @example 119: * splitPathInFrontmatter("a, b") // returns ["a", "b"] 120: * splitPathInFrontmatter("a, src/*.{ts,tsx}") // returns ["a", "src/*.ts", "src/*.tsx"] 121: * splitPathInFrontmatter("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"] 122: * splitPathInFrontmatter(["a", "src/*.{ts,tsx}"]) // returns ["a", "src/*.ts", "src/*.tsx"] 123: */ 124: export function splitPathInFrontmatter(input: string | string[]): string[] { 125: if (Array.isArray(input)) { 126: return input.flatMap(splitPathInFrontmatter) 127: } 128: if (typeof input !== 'string') { 129: return [] 130: } 131: // Split by comma while respecting braces 132: const parts: string[] = [] 133: let current = '' 134: let braceDepth = 0 135: for (let i = 0; i < input.length; i++) { 136: const char = input[i] 137: if (char === '{') { 138: braceDepth++ 139: current += char 140: } else if (char === '}') { 141: braceDepth-- 142: current += char 143: } else if (char === ',' && braceDepth === 0) { 144: // Split here - we're at a comma outside of braces 145: const trimmed = current.trim() 146: if (trimmed) { 147: parts.push(trimmed) 148: } 149: current = '' 150: } else { 151: current += char 152: } 153: } 154: // Add the last part 155: const trimmed = current.trim() 156: if (trimmed) { 157: parts.push(trimmed) 158: } 159: // Expand brace patterns in each part 160: return parts 161: .filter(p => p.length > 0) 162: .flatMap(pattern => expandBraces(pattern)) 163: } 164: /** 165: * Expands brace patterns in a glob string. 166: * @example 167: * expandBraces("src/*.{ts,tsx}") // returns ["src/*.ts", "src/*.tsx"] 168: * expandBraces("{a,b}/{c,d}") // returns ["a/c", "a/d", "b/c", "b/d"] 169: */ 170: function expandBraces(pattern: string): string[] { 171: // Find the first brace group 172: const braceMatch = pattern.match(/^([^{]*)\{([^}]+)\}(.*)$/) 173: if (!braceMatch) { 174: // No braces found, return pattern as-is 175: return [pattern] 176: } 177: const prefix = braceMatch[1] || '' 178: const alternatives = braceMatch[2] || '' 179: const suffix = braceMatch[3] || '' 180: // Split alternatives by comma and expand each one 181: const parts = alternatives.split(',').map(alt => alt.trim()) 182: // Recursively expand remaining braces in suffix 183: const expanded: string[] = [] 184: for (const part of parts) { 185: const combined = prefix + part + suffix 186: // Recursively handle additional brace groups 187: const furtherExpanded = expandBraces(combined) 188: expanded.push(...furtherExpanded) 189: } 190: return expanded 191: } 192: /** 193: * Parses a positive integer value from frontmatter. 194: * Handles both number and string representations. 195: * 196: * @param value The raw value from frontmatter (could be number, string, or undefined) 197: * @returns The parsed positive integer, or undefined if invalid or not provided 198: */ 199: export function parsePositiveIntFromFrontmatter( 200: value: unknown, 201: ): number | undefined { 202: if (value === undefined || value === null) { 203: return undefined 204: } 205: const parsed = typeof value === 'number' ? value : parseInt(String(value), 10) 206: if (Number.isInteger(parsed) && parsed > 0) { 207: return parsed 208: } 209: return undefined 210: } 211: /** 212: * Validate and coerce a description value from frontmatter. 213: * 214: * Strings are returned as-is (trimmed). Primitive values (numbers, booleans) 215: * are coerced to strings via String(). Non-scalar values (arrays, objects) 216: * are invalid and are logged then omitted. Null, undefined, and 217: * empty/whitespace-only strings return null so callers can fall back to 218: * a default. 219: * 220: * @param value - The raw frontmatter description value 221: * @param componentName - The skill/command/agent/style name for log messages 222: * @param pluginName - The plugin name, if this came from a plugin 223: */ 224: export function coerceDescriptionToString( 225: value: unknown, 226: componentName?: string, 227: pluginName?: string, 228: ): string | null { 229: if (value == null) { 230: return null 231: } 232: if (typeof value === 'string') { 233: return value.trim() || null 234: } 235: if (typeof value === 'number' || typeof value === 'boolean') { 236: return String(value) 237: } 238: // Non-scalar descriptions (arrays, objects) are invalid — log and omit 239: const source = pluginName 240: ? `${pluginName}:${componentName}` 241: : (componentName ?? 'unknown') 242: logForDebugging(`Description invalid for ${source} - omitting`, { 243: level: 'warn', 244: }) 245: return null 246: } 247: export function parseBooleanFrontmatter(value: unknown): boolean { 248: return value === true || value === 'true' 249: } 250: export type FrontmatterShell = 'bash' | 'powershell' 251: const FRONTMATTER_SHELLS: readonly FrontmatterShell[] = ['bash', 'powershell'] 252: export function parseShellFrontmatter( 253: value: unknown, 254: source: string, 255: ): FrontmatterShell | undefined { 256: if (value == null) { 257: return undefined 258: } 259: const normalized = String(value).trim().toLowerCase() 260: if (normalized === '') { 261: return undefined 262: } 263: if ((FRONTMATTER_SHELLS as readonly string[]).includes(normalized)) { 264: return normalized as FrontmatterShell 265: } 266: logForDebugging( 267: `Frontmatter 'shell: ${value}' in ${source} is not recognized. Valid values: ${FRONTMATTER_SHELLS.join(', ')}. Falling back to bash.`, 268: { level: 'warn' }, 269: ) 270: return undefined 271: }

File: src/utils/fsOperations.ts

typescript 1: import * as fs from 'fs' 2: import { 3: mkdir as mkdirPromise, 4: open, 5: readdir as readdirPromise, 6: readFile as readFilePromise, 7: rename as renamePromise, 8: rmdir as rmdirPromise, 9: rm as rmPromise, 10: stat as statPromise, 11: unlink as unlinkPromise, 12: } from 'fs/promises' 13: import { homedir } from 'os' 14: import * as nodePath from 'path' 15: import { getErrnoCode } from './errors.js' 16: import { slowLogging } from './slowOperations.js' 17: export type FsOperations = { 18: cwd(): string 19: existsSync(path: string): boolean 20: stat(path: string): Promise<fs.Stats> 21: readdir(path: string): Promise<fs.Dirent[]> 22: unlink(path: string): Promise<void> 23: rmdir(path: string): Promise<void> 24: rm( 25: path: string, 26: options?: { recursive?: boolean; force?: boolean }, 27: ): Promise<void> 28: mkdir(path: string, options?: { mode?: number }): Promise<void> 29: readFile(path: string, options: { encoding: BufferEncoding }): Promise<string> 30: rename(oldPath: string, newPath: string): Promise<void> 31: statSync(path: string): fs.Stats 32: lstatSync(path: string): fs.Stats 33: readFileSync( 34: path: string, 35: options: { 36: encoding: BufferEncoding 37: }, 38: ): string 39: readFileBytesSync(path: string): Buffer 40: readSync( 41: path: string, 42: options: { 43: length: number 44: }, 45: ): { 46: buffer: Buffer 47: bytesRead: number 48: } 49: appendFileSync(path: string, data: string, options?: { mode?: number }): void 50: copyFileSync(src: string, dest: string): void 51: unlinkSync(path: string): void 52: renameSync(oldPath: string, newPath: string): void 53: linkSync(target: string, path: string): void 54: symlinkSync( 55: target: string, 56: path: string, 57: type?: 'dir' | 'file' | 'junction', 58: ): void 59: readlinkSync(path: string): string 60: realpathSync(path: string): string 61: mkdirSync( 62: path: string, 63: options?: { 64: mode?: number 65: }, 66: ): void 67: readdirSync(path: string): fs.Dirent[] 68: readdirStringSync(path: string): string[] 69: isDirEmptySync(path: string): boolean 70: rmdirSync(path: string): void 71: rmSync( 72: path: string, 73: options?: { 74: recursive?: boolean 75: force?: boolean 76: }, 77: ): void 78: createWriteStream(path: string): fs.WriteStream 79: readFileBytes(path: string, maxBytes?: number): Promise<Buffer> 80: } 81: export function safeResolvePath( 82: fs: FsOperations, 83: filePath: string, 84: ): { resolvedPath: string; isSymlink: boolean; isCanonical: boolean } { 85: if (filePath.startsWith('//') || filePath.startsWith('\\\\')) { 86: return { resolvedPath: filePath, isSymlink: false, isCanonical: false } 87: } 88: try { 89: // Check for special file types (FIFOs, sockets, devices) before calling realpathSync. 90: // realpathSync can block on FIFOs waiting for a writer, causing hangs. 91: // If the file doesn't exist, lstatSync throws ENOENT which the catch 92: const stats = fs.lstatSync(filePath) 93: if ( 94: stats.isFIFO() || 95: stats.isSocket() || 96: stats.isCharacterDevice() || 97: stats.isBlockDevice() 98: ) { 99: return { resolvedPath: filePath, isSymlink: false, isCanonical: false } 100: } 101: const resolvedPath = fs.realpathSync(filePath) 102: return { 103: resolvedPath, 104: isSymlink: resolvedPath !== filePath, 105: isCanonical: true, 106: } 107: } catch (_error) { 108: return { resolvedPath: filePath, isSymlink: false, isCanonical: false } 109: } 110: } 111: export function isDuplicatePath( 112: fs: FsOperations, 113: filePath: string, 114: loadedPaths: Set<string>, 115: ): boolean { 116: const { resolvedPath } = safeResolvePath(fs, filePath) 117: if (loadedPaths.has(resolvedPath)) { 118: return true 119: } 120: loadedPaths.add(resolvedPath) 121: return false 122: } 123: export function resolveDeepestExistingAncestorSync( 124: fs: FsOperations, 125: absolutePath: string, 126: ): string | undefined { 127: let dir = absolutePath 128: const segments: string[] = [] 129: while (dir !== nodePath.dirname(dir)) { 130: let st: fs.Stats 131: try { 132: st = fs.lstatSync(dir) 133: } catch { 134: segments.unshift(nodePath.basename(dir)) 135: dir = nodePath.dirname(dir) 136: continue 137: } 138: if (st.isSymbolicLink()) { 139: try { 140: const resolved = fs.realpathSync(dir) 141: return segments.length === 0 142: ? resolved 143: : nodePath.join(resolved, ...segments) 144: } catch { 145: const target = fs.readlinkSync(dir) 146: const absTarget = nodePath.isAbsolute(target) 147: ? target 148: : nodePath.resolve(nodePath.dirname(dir), target) 149: return segments.length === 0 150: ? absTarget 151: : nodePath.join(absTarget, ...segments) 152: } 153: } 154: try { 155: const resolved = fs.realpathSync(dir) 156: if (resolved !== dir) { 157: return segments.length === 0 158: ? resolved 159: : nodePath.join(resolved, ...segments) 160: } 161: } catch { 162: } 163: return undefined 164: } 165: return undefined 166: } 167: export function getPathsForPermissionCheck(inputPath: string): string[] { 168: let path = inputPath 169: if (path === '~') { 170: path = homedir().normalize('NFC') 171: } else if (path.startsWith('~/')) { 172: path = nodePath.join(homedir().normalize('NFC'), path.slice(2)) 173: } 174: const pathSet = new Set<string>() 175: const fsImpl = getFsImplementation() 176: pathSet.add(path) 177: if (path.startsWith('//') || path.startsWith('\\\\')) { 178: return Array.from(pathSet) 179: } 180: // Follow the symlink chain, collecting ALL intermediate targets 181: // This handles cases like: test.txt -> /etc/passwd -> /private/etc/passwd 182: // We want to check all three paths, not just test.txt and /private/etc/passwd 183: try { 184: let currentPath = path 185: const visited = new Set<string>() 186: const maxDepth = 40 // Prevent runaway loops, matches typical SYMLOOP_MAX 187: for (let depth = 0; depth < maxDepth; depth++) { 188: // Prevent infinite loops from circular symlinks 189: if (visited.has(currentPath)) { 190: break 191: } 192: visited.add(currentPath) 193: if (!fsImpl.existsSync(currentPath)) { 194: // Path doesn't exist (new file case). existsSync follows symlinks, 195: if (currentPath === path) { 196: const resolved = resolveDeepestExistingAncestorSync(fsImpl, path) 197: if (resolved !== undefined) { 198: pathSet.add(resolved) 199: } 200: } 201: break 202: } 203: const stats = fsImpl.lstatSync(currentPath) 204: if ( 205: stats.isFIFO() || 206: stats.isSocket() || 207: stats.isCharacterDevice() || 208: stats.isBlockDevice() 209: ) { 210: break 211: } 212: if (!stats.isSymbolicLink()) { 213: break 214: } 215: const target = fsImpl.readlinkSync(currentPath) 216: const absoluteTarget = nodePath.isAbsolute(target) 217: ? target 218: : nodePath.resolve(nodePath.dirname(currentPath), target) 219: pathSet.add(absoluteTarget) 220: currentPath = absoluteTarget 221: } 222: } catch { 223: } 224: const { resolvedPath, isSymlink } = safeResolvePath(fsImpl, path) 225: if (isSymlink && resolvedPath !== path) { 226: pathSet.add(resolvedPath) 227: } 228: return Array.from(pathSet) 229: } 230: export const NodeFsOperations: FsOperations = { 231: cwd() { 232: return process.cwd() 233: }, 234: existsSync(fsPath) { 235: using _ = slowLogging`fs.existsSync(${fsPath})` 236: return fs.existsSync(fsPath) 237: }, 238: async stat(fsPath) { 239: return statPromise(fsPath) 240: }, 241: async readdir(fsPath) { 242: return readdirPromise(fsPath, { withFileTypes: true }) 243: }, 244: async unlink(fsPath) { 245: return unlinkPromise(fsPath) 246: }, 247: async rmdir(fsPath) { 248: return rmdirPromise(fsPath) 249: }, 250: async rm(fsPath, options) { 251: return rmPromise(fsPath, options) 252: }, 253: async mkdir(dirPath, options) { 254: try { 255: await mkdirPromise(dirPath, { recursive: true, ...options }) 256: } catch (e) { 257: // Bun/Windows: recursive:true throws EEXIST on directories with the 258: // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini). 259: // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir 260: // (bun-internal src/sys.zig existsAtType). The dir exists; ignore. 261: // https://github.com/anthropics/claude-code/issues/30924 262: if (getErrnoCode(e) !== 'EEXIST') throw e 263: } 264: }, 265: async readFile(fsPath, options) { 266: return readFilePromise(fsPath, { encoding: options.encoding }) 267: }, 268: async rename(oldPath, newPath) { 269: return renamePromise(oldPath, newPath) 270: }, 271: statSync(fsPath) { 272: using _ = slowLogging`fs.statSync(${fsPath})` 273: return fs.statSync(fsPath) 274: }, 275: lstatSync(fsPath) { 276: using _ = slowLogging`fs.lstatSync(${fsPath})` 277: return fs.lstatSync(fsPath) 278: }, 279: readFileSync(fsPath, options) { 280: using _ = slowLogging`fs.readFileSync(${fsPath})` 281: return fs.readFileSync(fsPath, { encoding: options.encoding }) 282: }, 283: readFileBytesSync(fsPath) { 284: using _ = slowLogging`fs.readFileBytesSync(${fsPath})` 285: return fs.readFileSync(fsPath) 286: }, 287: readSync(fsPath, options) { 288: using _ = slowLogging`fs.readSync(${fsPath}, ${options.length} bytes)` 289: let fd: number | undefined = undefined 290: try { 291: fd = fs.openSync(fsPath, 'r') 292: const buffer = Buffer.alloc(options.length) 293: const bytesRead = fs.readSync(fd, buffer, 0, options.length, 0) 294: return { buffer, bytesRead } 295: } finally { 296: if (fd) fs.closeSync(fd) 297: } 298: }, 299: appendFileSync(path, data, options) { 300: using _ = slowLogging`fs.appendFileSync(${path}, ${data.length} chars)` 301: // For new files with explicit mode, use 'ax' (atomic create-with-mode) to avoid 302: // TOCTOU race between existence check and open. Fall back to normal append if exists. 303: if (options?.mode !== undefined) { 304: try { 305: const fd = fs.openSync(path, 'ax', options.mode) 306: try { 307: fs.appendFileSync(fd, data) 308: } finally { 309: fs.closeSync(fd) 310: } 311: return 312: } catch (e) { 313: if (getErrnoCode(e) !== 'EEXIST') throw e 314: // File exists — fall through to normal append 315: } 316: } 317: fs.appendFileSync(path, data) 318: }, 319: copyFileSync(src, dest) { 320: using _ = slowLogging`fs.copyFileSync(${src} → ${dest})` 321: fs.copyFileSync(src, dest) 322: }, 323: unlinkSync(path: string) { 324: using _ = slowLogging`fs.unlinkSync(${path})` 325: fs.unlinkSync(path) 326: }, 327: renameSync(oldPath: string, newPath: string) { 328: using _ = slowLogging`fs.renameSync(${oldPath} → ${newPath})` 329: fs.renameSync(oldPath, newPath) 330: }, 331: linkSync(target: string, path: string) { 332: using _ = slowLogging`fs.linkSync(${target} → ${path})` 333: fs.linkSync(target, path) 334: }, 335: symlinkSync( 336: target: string, 337: path: string, 338: type?: 'dir' | 'file' | 'junction', 339: ) { 340: using _ = slowLogging`fs.symlinkSync(${target} → ${path})` 341: fs.symlinkSync(target, path, type) 342: }, 343: readlinkSync(path: string) { 344: using _ = slowLogging`fs.readlinkSync(${path})` 345: return fs.readlinkSync(path) 346: }, 347: realpathSync(path: string) { 348: using _ = slowLogging`fs.realpathSync(${path})` 349: return fs.realpathSync(path).normalize('NFC') 350: }, 351: mkdirSync(dirPath, options) { 352: using _ = slowLogging`fs.mkdirSync(${dirPath})` 353: const mkdirOptions: { recursive: boolean; mode?: number } = { 354: recursive: true, 355: } 356: if (options?.mode !== undefined) { 357: mkdirOptions.mode = options.mode 358: } 359: try { 360: fs.mkdirSync(dirPath, mkdirOptions) 361: } catch (e) { 362: // Bun/Windows: recursive:true throws EEXIST on directories with the 363: // FILE_ATTRIBUTE_READONLY bit set (Group Policy, OneDrive, desktop.ini). 364: // Bun's directoryExistsAt misclassifies DIRECTORY+READONLY as not-a-dir 365: // (bun-internal src/sys.zig existsAtType). The dir exists; ignore. 366: // https://github.com/anthropics/claude-code/issues/30924 367: if (getErrnoCode(e) !== 'EEXIST') throw e 368: } 369: }, 370: readdirSync(dirPath) { 371: using _ = slowLogging`fs.readdirSync(${dirPath})` 372: return fs.readdirSync(dirPath, { withFileTypes: true }) 373: }, 374: readdirStringSync(dirPath) { 375: using _ = slowLogging`fs.readdirStringSync(${dirPath})` 376: return fs.readdirSync(dirPath) 377: }, 378: isDirEmptySync(dirPath) { 379: using _ = slowLogging`fs.isDirEmptySync(${dirPath})` 380: const files = this.readdirSync(dirPath) 381: return files.length === 0 382: }, 383: rmdirSync(dirPath) { 384: using _ = slowLogging`fs.rmdirSync(${dirPath})` 385: fs.rmdirSync(dirPath) 386: }, 387: rmSync(path, options) { 388: using _ = slowLogging`fs.rmSync(${path})` 389: fs.rmSync(path, options) 390: }, 391: createWriteStream(path: string) { 392: return fs.createWriteStream(path) 393: }, 394: async readFileBytes(fsPath: string, maxBytes?: number) { 395: if (maxBytes === undefined) { 396: return readFilePromise(fsPath) 397: } 398: const handle = await open(fsPath, 'r') 399: try { 400: const { size } = await handle.stat() 401: const readSize = Math.min(size, maxBytes) 402: const buffer = Buffer.allocUnsafe(readSize) 403: let offset = 0 404: while (offset < readSize) { 405: const { bytesRead } = await handle.read( 406: buffer, 407: offset, 408: readSize - offset, 409: offset, 410: ) 411: if (bytesRead === 0) break 412: offset += bytesRead 413: } 414: return offset < readSize ? buffer.subarray(0, offset) : buffer 415: } finally { 416: await handle.close() 417: } 418: }, 419: } 420: // The currently active filesystem implementation 421: let activeFs: FsOperations = NodeFsOperations 422: /** 423: * Overrides the filesystem implementation. Note: This function does not 424: * automatically update cwd. 425: * @param implementation The filesystem implementation to use 426: */ 427: export function setFsImplementation(implementation: FsOperations): void { 428: activeFs = implementation 429: } 430: /** 431: * Gets the currently active filesystem implementation 432: * @returns The currently active filesystem implementation 433: */ 434: export function getFsImplementation(): FsOperations { 435: return activeFs 436: } 437: /** 438: * Resets the filesystem implementation to the default Node.js implementation. 439: * Note: This function does not automatically update cwd. 440: */ 441: export function setOriginalFsImplementation(): void { 442: activeFs = NodeFsOperations 443: } 444: export type ReadFileRangeResult = { 445: content: string 446: bytesRead: number 447: bytesTotal: number 448: } 449: /** 450: * Read up to `maxBytes` from a file starting at `offset`. 451: * Returns a flat string from Buffer — no sliced string references to a 452: * larger parent. Returns null if the file is smaller than the offset. 453: */ 454: export async function readFileRange( 455: path: string, 456: offset: number, 457: maxBytes: number, 458: ): Promise<ReadFileRangeResult | null> { 459: await using fh = await open(path, 'r') 460: const size = (await fh.stat()).size 461: if (size <= offset) { 462: return null 463: } 464: const bytesToRead = Math.min(size - offset, maxBytes) 465: const buffer = Buffer.allocUnsafe(bytesToRead) 466: let totalRead = 0 467: while (totalRead < bytesToRead) { 468: const { bytesRead } = await fh.read( 469: buffer, 470: totalRead, 471: bytesToRead - totalRead, 472: offset + totalRead, 473: ) 474: if (bytesRead === 0) { 475: break 476: } 477: totalRead += bytesRead 478: } 479: return { 480: content: buffer.toString('utf8', 0, totalRead), 481: bytesRead: totalRead, 482: bytesTotal: size, 483: } 484: } 485: export async function tailFile( 486: path: string, 487: maxBytes: number, 488: ): Promise<ReadFileRangeResult> { 489: await using fh = await open(path, 'r') 490: const size = (await fh.stat()).size 491: if (size === 0) { 492: return { content: '', bytesRead: 0, bytesTotal: 0 } 493: } 494: const offset = Math.max(0, size - maxBytes) 495: const bytesToRead = size - offset 496: const buffer = Buffer.allocUnsafe(bytesToRead) 497: let totalRead = 0 498: while (totalRead < bytesToRead) { 499: const { bytesRead } = await fh.read( 500: buffer, 501: totalRead, 502: bytesToRead - totalRead, 503: offset + totalRead, 504: ) 505: if (bytesRead === 0) { 506: break 507: } 508: totalRead += bytesRead 509: } 510: return { 511: content: buffer.toString('utf8', 0, totalRead), 512: bytesRead: totalRead, 513: bytesTotal: size, 514: } 515: } 516: export async function* readLinesReverse( 517: path: string, 518: ): AsyncGenerator<string, void, undefined> { 519: const CHUNK_SIZE = 1024 * 4 520: const fileHandle = await open(path, 'r') 521: try { 522: const stats = await fileHandle.stat() 523: let position = stats.size 524: let remainder = Buffer.alloc(0) 525: const buffer = Buffer.alloc(CHUNK_SIZE) 526: while (position > 0) { 527: const currentChunkSize = Math.min(CHUNK_SIZE, position) 528: position -= currentChunkSize 529: await fileHandle.read(buffer, 0, currentChunkSize, position) 530: const combined = Buffer.concat([ 531: buffer.subarray(0, currentChunkSize), 532: remainder, 533: ]) 534: const firstNewline = combined.indexOf(0x0a) 535: if (firstNewline === -1) { 536: remainder = combined 537: continue 538: } 539: remainder = Buffer.from(combined.subarray(0, firstNewline)) 540: const lines = combined.toString('utf8', firstNewline + 1).split('\n') 541: for (let i = lines.length - 1; i >= 0; i--) { 542: const line = lines[i]! 543: if (line) { 544: yield line 545: } 546: } 547: } 548: if (remainder.length > 0) { 549: yield remainder.toString('utf8') 550: } 551: } finally { 552: await fileHandle.close() 553: } 554: }

File: src/utils/fullscreen.ts

typescript 1: import { spawnSync } from 'child_process' 2: import { getIsInteractive } from '../bootstrap/state.js' 3: import { logForDebugging } from './debug.js' 4: import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' 5: import { execFileNoThrow } from './execFileNoThrow.js' 6: let loggedTmuxCcDisable = false 7: let checkedTmuxMouseHint = false 8: let tmuxControlModeProbed: boolean | undefined 9: function isTmuxControlModeEnvHeuristic(): boolean { 10: if (!process.env.TMUX) return false 11: if (process.env.TERM_PROGRAM !== 'iTerm.app') return false 12: const term = process.env.TERM ?? '' 13: return !term.startsWith('screen') && !term.startsWith('tmux') 14: } 15: function probeTmuxControlModeSync(): void { 16: tmuxControlModeProbed = isTmuxControlModeEnvHeuristic() 17: if (tmuxControlModeProbed) return 18: if (!process.env.TMUX) return 19: if (process.env.TERM_PROGRAM) return 20: let result 21: try { 22: result = spawnSync( 23: 'tmux', 24: ['display-message', '-p', '#{client_control_mode}'], 25: { encoding: 'utf8', timeout: 2000 }, 26: ) 27: } catch { 28: return 29: } 30: if (result.status !== 0) return 31: tmuxControlModeProbed = result.stdout.trim() === '1' 32: } 33: export function isTmuxControlMode(): boolean { 34: if (tmuxControlModeProbed === undefined) probeTmuxControlModeSync() 35: return tmuxControlModeProbed ?? false 36: } 37: export function _resetTmuxControlModeProbeForTesting(): void { 38: tmuxControlModeProbed = undefined 39: loggedTmuxCcDisable = false 40: } 41: export function isFullscreenEnvEnabled(): boolean { 42: if (isEnvDefinedFalsy(process.env.CLAUDE_CODE_NO_FLICKER)) return false 43: if (isEnvTruthy(process.env.CLAUDE_CODE_NO_FLICKER)) return true 44: if (isTmuxControlMode()) { 45: if (!loggedTmuxCcDisable) { 46: loggedTmuxCcDisable = true 47: logForDebugging( 48: 'fullscreen disabled: tmux -CC (iTerm2 integration mode) detected · set CLAUDE_CODE_NO_FLICKER=1 to override', 49: ) 50: } 51: return false 52: } 53: return process.env.USER_TYPE === 'ant' 54: } 55: export function isMouseTrackingEnabled(): boolean { 56: return !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MOUSE) 57: } 58: export function isMouseClicksDisabled(): boolean { 59: return isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MOUSE_CLICKS) 60: } 61: export function isFullscreenActive(): boolean { 62: return getIsInteractive() && isFullscreenEnvEnabled() 63: } 64: export async function maybeGetTmuxMouseHint(): Promise<string | null> { 65: if (!process.env.TMUX) return null 66: if (!isFullscreenActive() || isTmuxControlMode()) return null 67: if (checkedTmuxMouseHint) return null 68: checkedTmuxMouseHint = true 69: const { stdout, code } = await execFileNoThrow( 70: 'tmux', 71: ['show', '-Av', 'mouse'], 72: { useCwd: false, timeout: 2000 }, 73: ) 74: if (code !== 0 || stdout.trim() === 'on') return null 75: return "tmux detected · scroll with PgUp/PgDn · or add 'set -g mouse on' to ~/.tmux.conf for wheel scroll" 76: } 77: export function _resetForTesting(): void { 78: loggedTmuxCcDisable = false 79: checkedTmuxMouseHint = false 80: }

File: src/utils/generatedFiles.ts

typescript 1: import { basename, extname, posix, sep } from 'path' 2: const EXCLUDED_FILENAMES = new Set([ 3: 'package-lock.json', 4: 'yarn.lock', 5: 'pnpm-lock.yaml', 6: 'bun.lockb', 7: 'bun.lock', 8: 'composer.lock', 9: 'gemfile.lock', 10: 'cargo.lock', 11: 'poetry.lock', 12: 'pipfile.lock', 13: 'shrinkwrap.json', 14: 'npm-shrinkwrap.json', 15: ]) 16: const EXCLUDED_EXTENSIONS = new Set([ 17: '.lock', 18: '.min.js', 19: '.min.css', 20: '.min.html', 21: '.bundle.js', 22: '.bundle.css', 23: '.generated.ts', 24: '.generated.js', 25: '.d.ts', 26: ]) 27: const EXCLUDED_DIRECTORIES = [ 28: '/dist/', 29: '/build/', 30: '/out/', 31: '/output/', 32: '/node_modules/', 33: '/vendor/', 34: '/vendored/', 35: '/third_party/', 36: '/third-party/', 37: '/external/', 38: '/.next/', 39: '/.nuxt/', 40: '/.svelte-kit/', 41: '/coverage/', 42: '/__pycache__/', 43: '/.tox/', 44: '/venv/', 45: '/.venv/', 46: '/target/release/', 47: '/target/debug/', 48: ] 49: const EXCLUDED_FILENAME_PATTERNS = [ 50: /^.*\.min\.[a-z]+$/i, 51: /^.*-min\.[a-z]+$/i, 52: /^.*\.bundle\.[a-z]+$/i, 53: /^.*\.generated\.[a-z]+$/i, 54: /^.*\.gen\.[a-z]+$/i, 55: /^.*\.auto\.[a-z]+$/i, 56: /^.*_generated\.[a-z]+$/i, 57: /^.*_gen\.[a-z]+$/i, 58: /^.*\.pb\.(go|js|ts|py|rb)$/i, 59: /^.*_pb2?\.py$/i, 60: /^.*\.pb\.h$/i, 61: /^.*\.grpc\.[a-z]+$/i, 62: /^.*\.swagger\.[a-z]+$/i, 63: /^.*\.openapi\.[a-z]+$/i, 64: ] 65: export function isGeneratedFile(filePath: string): boolean { 66: const normalizedPath = 67: posix.sep + filePath.split(sep).join(posix.sep).replace(/^\/+/, '') 68: const fileName = basename(filePath).toLowerCase() 69: const ext = extname(filePath).toLowerCase() 70: // Check exact filename matches 71: if (EXCLUDED_FILENAMES.has(fileName)) { 72: return true 73: } 74: // Check extension matches 75: if (EXCLUDED_EXTENSIONS.has(ext)) { 76: return true 77: } 78: // Check for compound extensions like .min.js 79: const parts = fileName.split('.') 80: if (parts.length > 2) { 81: const compoundExt = '.' + parts.slice(-2).join('.') 82: if (EXCLUDED_EXTENSIONS.has(compoundExt)) { 83: return true 84: } 85: } 86: for (const dir of EXCLUDED_DIRECTORIES) { 87: if (normalizedPath.includes(dir)) { 88: return true 89: } 90: } 91: for (const pattern of EXCLUDED_FILENAME_PATTERNS) { 92: if (pattern.test(fileName)) { 93: return true 94: } 95: } 96: return false 97: } 98: export function filterGeneratedFiles(files: string[]): string[] { 99: return files.filter(file => !isGeneratedFile(file)) 100: }

File: src/utils/generators.ts

typescript 1: const NO_VALUE = Symbol('NO_VALUE') 2: export async function lastX<A>(as: AsyncGenerator<A>): Promise<A> { 3: let lastValue: A | typeof NO_VALUE = NO_VALUE 4: for await (const a of as) { 5: lastValue = a 6: } 7: if (lastValue === NO_VALUE) { 8: throw new Error('No items in generator') 9: } 10: return lastValue 11: } 12: export async function returnValue<A>( 13: as: AsyncGenerator<unknown, A>, 14: ): Promise<A> { 15: let e 16: do { 17: e = await as.next() 18: } while (!e.done) 19: return e.value 20: } 21: type QueuedGenerator<A> = { 22: done: boolean | void 23: value: A | void 24: generator: AsyncGenerator<A, void> 25: promise: Promise<QueuedGenerator<A>> 26: } 27: export async function* all<A>( 28: generators: AsyncGenerator<A, void>[], 29: concurrencyCap = Infinity, 30: ): AsyncGenerator<A, void> { 31: const next = (generator: AsyncGenerator<A, void>) => { 32: const promise: Promise<QueuedGenerator<A>> = generator 33: .next() 34: .then(({ done, value }) => ({ 35: done, 36: value, 37: generator, 38: promise, 39: })) 40: return promise 41: } 42: const waiting = [...generators] 43: const promises = new Set<Promise<QueuedGenerator<A>>>() 44: while (promises.size < concurrencyCap && waiting.length > 0) { 45: const gen = waiting.shift()! 46: promises.add(next(gen)) 47: } 48: while (promises.size > 0) { 49: const { done, value, generator, promise } = await Promise.race(promises) 50: promises.delete(promise) 51: if (!done) { 52: promises.add(next(generator)) 53: if (value !== undefined) { 54: yield value 55: } 56: } else if (waiting.length > 0) { 57: const nextGen = waiting.shift()! 58: promises.add(next(nextGen)) 59: } 60: } 61: } 62: export async function toArray<A>( 63: generator: AsyncGenerator<A, void>, 64: ): Promise<A[]> { 65: const result: A[] = [] 66: for await (const a of generator) { 67: result.push(a) 68: } 69: return result 70: } 71: export async function* fromArray<T>(values: T[]): AsyncGenerator<T, void> { 72: for (const value of values) { 73: yield value 74: } 75: }

File: src/utils/genericProcessUtils.ts

typescript 1: import { 2: execFileNoThrowWithCwd, 3: execSyncWithDefaults_DEPRECATED, 4: } from './execFileNoThrow.js' 5: export function isProcessRunning(pid: number): boolean { 6: if (pid <= 1) return false 7: try { 8: process.kill(pid, 0) 9: return true 10: } catch { 11: return false 12: } 13: } 14: export async function getAncestorPidsAsync( 15: pid: string | number, 16: maxDepth = 10, 17: ): Promise<number[]> { 18: if (process.platform === 'win32') { 19: const script = ` 20: $pid = ${String(pid)} 21: $ancestors = @() 22: for ($i = 0; $i -lt ${maxDepth}; $i++) { 23: $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$pid" -ErrorAction SilentlyContinue 24: if (-not $proc -or -not $proc.ParentProcessId -or $proc.ParentProcessId -eq 0) { break } 25: $pid = $proc.ParentProcessId 26: $ancestors += $pid 27: } 28: $ancestors -join ',' 29: `.trim() 30: const result = await execFileNoThrowWithCwd( 31: 'powershell.exe', 32: ['-NoProfile', '-Command', script], 33: { timeout: 3000 }, 34: ) 35: if (result.code !== 0 || !result.stdout?.trim()) { 36: return [] 37: } 38: return result.stdout 39: .trim() 40: .split(',') 41: .filter(Boolean) 42: .map(p => parseInt(p, 10)) 43: .filter(p => !isNaN(p)) 44: } 45: const script = `pid=${String(pid)}; for i in $(seq 1 ${maxDepth}); do ppid=$(ps -o ppid= -p $pid 2>/dev/null | tr -d ' '); if [ -z "$ppid" ] || [ "$ppid" = "0" ] || [ "$ppid" = "1" ]; then break; fi; echo $ppid; pid=$ppid; done` 46: const result = await execFileNoThrowWithCwd('sh', ['-c', script], { 47: timeout: 3000, 48: }) 49: if (result.code !== 0 || !result.stdout?.trim()) { 50: return [] 51: } 52: return result.stdout 53: .trim() 54: .split('\n') 55: .filter(Boolean) 56: .map(p => parseInt(p, 10)) 57: .filter(p => !isNaN(p)) 58: } 59: export function getProcessCommand(pid: string | number): string | null { 60: try { 61: const pidStr = String(pid) 62: const command = 63: process.platform === 'win32' 64: ? `powershell.exe -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ProcessId=${pidStr}\\").CommandLine"` 65: : `ps -o command= -p ${pidStr}` 66: const result = execSyncWithDefaults_DEPRECATED(command, { timeout: 1000 }) 67: return result ? result.trim() : null 68: } catch { 69: return null 70: } 71: } 72: export async function getAncestorCommandsAsync( 73: pid: string | number, 74: maxDepth = 10, 75: ): Promise<string[]> { 76: if (process.platform === 'win32') { 77: const script = ` 78: $currentPid = ${String(pid)} 79: $commands = @() 80: for ($i = 0; $i -lt ${maxDepth}; $i++) { 81: $proc = Get-CimInstance Win32_Process -Filter "ProcessId=$currentPid" -ErrorAction SilentlyContinue 82: if (-not $proc) { break } 83: if ($proc.CommandLine) { $commands += $proc.CommandLine } 84: if (-not $proc.ParentProcessId -or $proc.ParentProcessId -eq 0) { break } 85: $currentPid = $proc.ParentProcessId 86: } 87: $commands -join [char]0 88: `.trim() 89: const result = await execFileNoThrowWithCwd( 90: 'powershell.exe', 91: ['-NoProfile', '-Command', script], 92: { timeout: 3000 }, 93: ) 94: if (result.code !== 0 || !result.stdout?.trim()) { 95: return [] 96: } 97: return result.stdout.split('\0').filter(Boolean) 98: } 99: const script = `currentpid=${String(pid)}; for i in $(seq 1 ${maxDepth}); do cmd=$(ps -o command= -p $currentpid 2>/dev/null); if [ -n "$cmd" ]; then printf '%s\\0' "$cmd"; fi; ppid=$(ps -o ppid= -p $currentpid 2>/dev/null | tr -d ' '); if [ -z "$ppid" ] || [ "$ppid" = "0" ] || [ "$ppid" = "1" ]; then break; fi; currentpid=$ppid; done` 100: const result = await execFileNoThrowWithCwd('sh', ['-c', script], { 101: timeout: 3000, 102: }) 103: if (result.code !== 0 || !result.stdout?.trim()) { 104: return [] 105: } 106: return result.stdout.split('\0').filter(Boolean) 107: } 108: export function getChildPids(pid: string | number): number[] { 109: try { 110: const pidStr = String(pid) 111: const command = 112: process.platform === 'win32' 113: ? `powershell.exe -NoProfile -Command "(Get-CimInstance Win32_Process -Filter \\"ParentProcessId=${pidStr}\\").ProcessId"` 114: : `pgrep -P ${pidStr}` 115: const result = execSyncWithDefaults_DEPRECATED(command, { timeout: 1000 }) 116: if (!result) { 117: return [] 118: } 119: return result 120: .trim() 121: .split('\n') 122: .filter(Boolean) 123: .map(p => parseInt(p, 10)) 124: .filter(p => !isNaN(p)) 125: } catch { 126: return [] 127: } 128: }

File: src/utils/getWorktreePaths.ts

typescript 1: import { sep } from 'path' 2: import { logEvent } from '../services/analytics/index.js' 3: import { execFileNoThrowWithCwd } from './execFileNoThrow.js' 4: import { gitExe } from './git.js' 5: export async function getWorktreePaths(cwd: string): Promise<string[]> { 6: const startTime = Date.now() 7: const { stdout, code } = await execFileNoThrowWithCwd( 8: gitExe(), 9: ['worktree', 'list', '--porcelain'], 10: { 11: cwd, 12: preserveOutputOnError: false, 13: }, 14: ) 15: const durationMs = Date.now() - startTime 16: if (code !== 0) { 17: logEvent('tengu_worktree_detection', { 18: duration_ms: durationMs, 19: worktree_count: 0, 20: success: false, 21: }) 22: return [] 23: } 24: const worktreePaths = stdout 25: .split('\n') 26: .filter(line => line.startsWith('worktree ')) 27: .map(line => line.slice('worktree '.length).normalize('NFC')) 28: logEvent('tengu_worktree_detection', { 29: duration_ms: durationMs, 30: worktree_count: worktreePaths.length, 31: success: true, 32: }) 33: const currentWorktree = worktreePaths.find( 34: path => cwd === path || cwd.startsWith(path + sep), 35: ) 36: const otherWorktrees = worktreePaths 37: .filter(path => path !== currentWorktree) 38: .sort((a, b) => a.localeCompare(b)) 39: return currentWorktree ? [currentWorktree, ...otherWorktrees] : otherWorktrees 40: }

File: src/utils/getWorktreePathsPortable.ts

typescript 1: import { execFile as execFileCb } from 'child_process' 2: import { promisify } from 'util' 3: const execFileAsync = promisify(execFileCb) 4: export async function getWorktreePathsPortable(cwd: string): Promise<string[]> { 5: try { 6: const { stdout } = await execFileAsync( 7: 'git', 8: ['worktree', 'list', '--porcelain'], 9: { cwd, timeout: 5000 }, 10: ) 11: if (!stdout) return [] 12: return stdout 13: .split('\n') 14: .filter(line => line.startsWith('worktree ')) 15: .map(line => line.slice('worktree '.length).normalize('NFC')) 16: } catch { 17: return [] 18: } 19: }

File: src/utils/ghPrStatus.ts

typescript 1: import { execFileNoThrow } from './execFileNoThrow.js' 2: import { getBranch, getDefaultBranch, getIsGit } from './git.js' 3: import { jsonParse } from './slowOperations.js' 4: export type PrReviewState = 5: | 'approved' 6: | 'pending' 7: | 'changes_requested' 8: | 'draft' 9: | 'merged' 10: | 'closed' 11: export type PrStatus = { 12: number: number 13: url: string 14: reviewState: PrReviewState 15: } 16: const GH_TIMEOUT_MS = 5000 17: export function deriveReviewState( 18: isDraft: boolean, 19: reviewDecision: string, 20: ): PrReviewState { 21: if (isDraft) return 'draft' 22: switch (reviewDecision) { 23: case 'APPROVED': 24: return 'approved' 25: case 'CHANGES_REQUESTED': 26: return 'changes_requested' 27: default: 28: return 'pending' 29: } 30: } 31: export async function fetchPrStatus(): Promise<PrStatus | null> { 32: const isGit = await getIsGit() 33: if (!isGit) return null 34: const [branch, defaultBranch] = await Promise.all([ 35: getBranch(), 36: getDefaultBranch(), 37: ]) 38: if (branch === defaultBranch) return null 39: const { stdout, code } = await execFileNoThrow( 40: 'gh', 41: [ 42: 'pr', 43: 'view', 44: '--json', 45: 'number,url,reviewDecision,isDraft,headRefName,state', 46: ], 47: { timeout: GH_TIMEOUT_MS, preserveOutputOnError: false }, 48: ) 49: if (code !== 0 || !stdout.trim()) return null 50: try { 51: const data = jsonParse(stdout) as { 52: number: number 53: url: string 54: reviewDecision: string 55: isDraft: boolean 56: headRefName: string 57: state: string 58: } 59: if ( 60: data.headRefName === defaultBranch || 61: data.headRefName === 'main' || 62: data.headRefName === 'master' 63: ) { 64: return null 65: } 66: if (data.state === 'MERGED' || data.state === 'CLOSED') { 67: return null 68: } 69: return { 70: number: data.number, 71: url: data.url, 72: reviewState: deriveReviewState(data.isDraft, data.reviewDecision), 73: } 74: } catch { 75: return null 76: } 77: }

File: src/utils/git.ts

typescript 1: import { createHash } from 'crypto' 2: import { readFileSync, realpathSync, statSync } from 'fs' 3: import { open, readFile, realpath, stat } from 'fs/promises' 4: import memoize from 'lodash-es/memoize.js' 5: import { basename, dirname, join, resolve, sep } from 'path' 6: import { hasBinaryExtension, isBinaryContent } from '../constants/files.js' 7: import { getCwd } from './cwd.js' 8: import { logForDebugging } from './debug.js' 9: import { logForDiagnosticsNoPII } from './diagLogs.js' 10: import { execFileNoThrow } from './execFileNoThrow.js' 11: import { getFsImplementation } from './fsOperations.js' 12: import { 13: getCachedBranch, 14: getCachedDefaultBranch, 15: getCachedHead, 16: getCachedRemoteUrl, 17: getWorktreeCountFromFs, 18: isShallowClone as isShallowCloneFs, 19: resolveGitDir, 20: } from './git/gitFilesystem.js' 21: import { logError } from './log.js' 22: import { memoizeWithLRU } from './memoize.js' 23: import { whichSync } from './which.js' 24: const GIT_ROOT_NOT_FOUND = Symbol('git-root-not-found') 25: const findGitRootImpl = memoizeWithLRU( 26: (startPath: string): string | typeof GIT_ROOT_NOT_FOUND => { 27: const startTime = Date.now() 28: logForDiagnosticsNoPII('info', 'find_git_root_started') 29: let current = resolve(startPath) 30: const root = current.substring(0, current.indexOf(sep) + 1) || sep 31: let statCount = 0 32: while (current !== root) { 33: try { 34: const gitPath = join(current, '.git') 35: statCount++ 36: const stat = statSync(gitPath) 37: if (stat.isDirectory() || stat.isFile()) { 38: logForDiagnosticsNoPII('info', 'find_git_root_completed', { 39: duration_ms: Date.now() - startTime, 40: stat_count: statCount, 41: found: true, 42: }) 43: return current.normalize('NFC') 44: } 45: } catch { 46: } 47: const parent = dirname(current) 48: if (parent === current) { 49: break 50: } 51: current = parent 52: } 53: try { 54: const gitPath = join(root, '.git') 55: statCount++ 56: const stat = statSync(gitPath) 57: if (stat.isDirectory() || stat.isFile()) { 58: logForDiagnosticsNoPII('info', 'find_git_root_completed', { 59: duration_ms: Date.now() - startTime, 60: stat_count: statCount, 61: found: true, 62: }) 63: return root.normalize('NFC') 64: } 65: } catch { 66: } 67: logForDiagnosticsNoPII('info', 'find_git_root_completed', { 68: duration_ms: Date.now() - startTime, 69: stat_count: statCount, 70: found: false, 71: }) 72: return GIT_ROOT_NOT_FOUND 73: }, 74: path => path, 75: 50, 76: ) 77: export const findGitRoot = createFindGitRoot() 78: function createFindGitRoot(): { 79: (startPath: string): string | null 80: cache: typeof findGitRootImpl.cache 81: } { 82: function wrapper(startPath: string): string | null { 83: const result = findGitRootImpl(startPath) 84: return result === GIT_ROOT_NOT_FOUND ? null : result 85: } 86: wrapper.cache = findGitRootImpl.cache 87: return wrapper 88: } 89: const resolveCanonicalRoot = memoizeWithLRU( 90: (gitRoot: string): string => { 91: try { 92: const gitContent = readFileSync(join(gitRoot, '.git'), 'utf-8').trim() 93: if (!gitContent.startsWith('gitdir:')) { 94: return gitRoot 95: } 96: const worktreeGitDir = resolve( 97: gitRoot, 98: gitContent.slice('gitdir:'.length).trim(), 99: ) 100: const commonDir = resolve( 101: worktreeGitDir, 102: readFileSync(join(worktreeGitDir, 'commondir'), 'utf-8').trim(), 103: ) 104: if (resolve(dirname(worktreeGitDir)) !== join(commonDir, 'worktrees')) { 105: return gitRoot 106: } 107: const backlink = realpathSync( 108: readFileSync(join(worktreeGitDir, 'gitdir'), 'utf-8').trim(), 109: ) 110: if (backlink !== join(realpathSync(gitRoot), '.git')) { 111: return gitRoot 112: } 113: if (basename(commonDir) !== '.git') { 114: return commonDir.normalize('NFC') 115: } 116: return dirname(commonDir).normalize('NFC') 117: } catch { 118: return gitRoot 119: } 120: }, 121: root => root, 122: 50, 123: ) 124: export const findCanonicalGitRoot = createFindCanonicalGitRoot() 125: function createFindCanonicalGitRoot(): { 126: (startPath: string): string | null 127: cache: typeof resolveCanonicalRoot.cache 128: } { 129: function wrapper(startPath: string): string | null { 130: const root = findGitRoot(startPath) 131: if (!root) { 132: return null 133: } 134: return resolveCanonicalRoot(root) 135: } 136: wrapper.cache = resolveCanonicalRoot.cache 137: return wrapper 138: } 139: export const gitExe = memoize((): string => { 140: return whichSync('git') || 'git' 141: }) 142: export const getIsGit = memoize(async (): Promise<boolean> => { 143: const startTime = Date.now() 144: logForDiagnosticsNoPII('info', 'is_git_check_started') 145: const isGit = findGitRoot(getCwd()) !== null 146: logForDiagnosticsNoPII('info', 'is_git_check_completed', { 147: duration_ms: Date.now() - startTime, 148: is_git: isGit, 149: }) 150: return isGit 151: }) 152: export function getGitDir(cwd: string): Promise<string | null> { 153: return resolveGitDir(cwd) 154: } 155: export async function isAtGitRoot(): Promise<boolean> { 156: const cwd = getCwd() 157: const gitRoot = findGitRoot(cwd) 158: if (!gitRoot) { 159: return false 160: } 161: try { 162: const [resolvedCwd, resolvedGitRoot] = await Promise.all([ 163: realpath(cwd), 164: realpath(gitRoot), 165: ]) 166: return resolvedCwd === resolvedGitRoot 167: } catch { 168: return cwd === gitRoot 169: } 170: } 171: export const dirIsInGitRepo = async (cwd: string): Promise<boolean> => { 172: return findGitRoot(cwd) !== null 173: } 174: export const getHead = async (): Promise<string> => { 175: return getCachedHead() 176: } 177: export const getBranch = async (): Promise<string> => { 178: return getCachedBranch() 179: } 180: export const getDefaultBranch = async (): Promise<string> => { 181: return getCachedDefaultBranch() 182: } 183: export const getRemoteUrl = async (): Promise<string | null> => { 184: return getCachedRemoteUrl() 185: } 186: export function normalizeGitRemoteUrl(url: string): string | null { 187: const trimmed = url.trim() 188: if (!trimmed) return null 189: const sshMatch = trimmed.match(/^git@([^:]+):(.+?)(?:\.git)?$/) 190: if (sshMatch && sshMatch[1] && sshMatch[2]) { 191: return `${sshMatch[1]}/${sshMatch[2]}`.toLowerCase() 192: } 193: const urlMatch = trimmed.match( 194: /^(?:https?|ssh):\/\/(?:[^@]+@)?([^/]+)\/(.+?)(?:\.git)?$/, 195: ) 196: if (urlMatch && urlMatch[1] && urlMatch[2]) { 197: const host = urlMatch[1] 198: const path = urlMatch[2] 199: if (isLocalHost(host) && path.startsWith('git/')) { 200: const proxyPath = path.slice(4) 201: const segments = proxyPath.split('/') 202: if (segments.length >= 3 && segments[0]!.includes('.')) { 203: return proxyPath.toLowerCase() 204: } 205: return `github.com/${proxyPath}`.toLowerCase() 206: } 207: return `${host}/${path}`.toLowerCase() 208: } 209: return null 210: } 211: export async function getRepoRemoteHash(): Promise<string | null> { 212: const remoteUrl = await getRemoteUrl() 213: if (!remoteUrl) return null 214: const normalized = normalizeGitRemoteUrl(remoteUrl) 215: if (!normalized) return null 216: const hash = createHash('sha256').update(normalized).digest('hex') 217: return hash.substring(0, 16) 218: } 219: export const getIsHeadOnRemote = async (): Promise<boolean> => { 220: const { code } = await execFileNoThrow(gitExe(), ['rev-parse', '@{u}'], { 221: preserveOutputOnError: false, 222: }) 223: return code === 0 224: } 225: export const hasUnpushedCommits = async (): Promise<boolean> => { 226: const { stdout, code } = await execFileNoThrow( 227: gitExe(), 228: ['rev-list', '--count', '@{u}..HEAD'], 229: { preserveOutputOnError: false }, 230: ) 231: return code === 0 && parseInt(stdout.trim(), 10) > 0 232: } 233: export const getIsClean = async (options?: { 234: ignoreUntracked?: boolean 235: }): Promise<boolean> => { 236: const args = ['--no-optional-locks', 'status', '--porcelain'] 237: if (options?.ignoreUntracked) { 238: args.push('-uno') 239: } 240: const { stdout } = await execFileNoThrow(gitExe(), args, { 241: preserveOutputOnError: false, 242: }) 243: return stdout.trim().length === 0 244: } 245: export const getChangedFiles = async (): Promise<string[]> => { 246: const { stdout } = await execFileNoThrow( 247: gitExe(), 248: ['--no-optional-locks', 'status', '--porcelain'], 249: { 250: preserveOutputOnError: false, 251: }, 252: ) 253: return stdout 254: .trim() 255: .split('\n') 256: .map(line => line.trim().split(' ', 2)[1]?.trim()) 257: .filter(line => typeof line === 'string') 258: } 259: export type GitFileStatus = { 260: tracked: string[] 261: untracked: string[] 262: } 263: export const getFileStatus = async (): Promise<GitFileStatus> => { 264: const { stdout } = await execFileNoThrow( 265: gitExe(), 266: ['--no-optional-locks', 'status', '--porcelain'], 267: { 268: preserveOutputOnError: false, 269: }, 270: ) 271: const tracked: string[] = [] 272: const untracked: string[] = [] 273: stdout 274: .trim() 275: .split('\n') 276: .filter(line => line.length > 0) 277: .forEach(line => { 278: const status = line.substring(0, 2) 279: const filename = line.substring(2).trim() 280: if (status === '??') { 281: untracked.push(filename) 282: } else if (filename) { 283: tracked.push(filename) 284: } 285: }) 286: return { tracked, untracked } 287: } 288: export const getWorktreeCount = async (): Promise<number> => { 289: return getWorktreeCountFromFs() 290: } 291: export const stashToCleanState = async (message?: string): Promise<boolean> => { 292: try { 293: const stashMessage = 294: message || `Claude Code auto-stash - ${new Date().toISOString()}` 295: const { untracked } = await getFileStatus() 296: if (untracked.length > 0) { 297: const { code: addCode } = await execFileNoThrow( 298: gitExe(), 299: ['add', ...untracked], 300: { preserveOutputOnError: false }, 301: ) 302: if (addCode !== 0) { 303: return false 304: } 305: } 306: const { code } = await execFileNoThrow( 307: gitExe(), 308: ['stash', 'push', '--message', stashMessage], 309: { preserveOutputOnError: false }, 310: ) 311: return code === 0 312: } catch (_) { 313: return false 314: } 315: } 316: export type GitRepoState = { 317: commitHash: string 318: branchName: string 319: remoteUrl: string | null 320: isHeadOnRemote: boolean 321: isClean: boolean 322: worktreeCount: number 323: } 324: export async function getGitState(): Promise<GitRepoState | null> { 325: try { 326: const [ 327: commitHash, 328: branchName, 329: remoteUrl, 330: isHeadOnRemote, 331: isClean, 332: worktreeCount, 333: ] = await Promise.all([ 334: getHead(), 335: getBranch(), 336: getRemoteUrl(), 337: getIsHeadOnRemote(), 338: getIsClean(), 339: getWorktreeCount(), 340: ]) 341: return { 342: commitHash, 343: branchName, 344: remoteUrl, 345: isHeadOnRemote, 346: isClean, 347: worktreeCount, 348: } 349: } catch (_) { 350: return null 351: } 352: } 353: export async function getGithubRepo(): Promise<string | null> { 354: const { parseGitRemote } = await import('./detectRepository.js') 355: const remoteUrl = await getRemoteUrl() 356: if (!remoteUrl) { 357: logForDebugging('Local GitHub repo: unknown') 358: return null 359: } 360: const parsed = parseGitRemote(remoteUrl) 361: if (parsed && parsed.host === 'github.com') { 362: const result = `${parsed.owner}/${parsed.name}` 363: logForDebugging(`Local GitHub repo: ${result}`) 364: return result 365: } 366: logForDebugging('Local GitHub repo: unknown') 367: return null 368: } 369: export type PreservedGitState = { 370: remote_base_sha: string | null 371: remote_base: string | null 372: patch: string 373: untracked_files: Array<{ path: string; content: string }> 374: format_patch: string | null 375: head_sha: string | null 376: branch_name: string | null 377: } 378: const MAX_FILE_SIZE_BYTES = 500 * 1024 * 1024 379: const MAX_TOTAL_SIZE_BYTES = 5 * 1024 * 1024 * 1024 380: const MAX_FILE_COUNT = 20000 381: const SNIFF_BUFFER_SIZE = 64 * 1024 382: export async function findRemoteBase(): Promise<string | null> { 383: const { stdout: trackingBranch, code: trackingCode } = await execFileNoThrow( 384: gitExe(), 385: ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], 386: { preserveOutputOnError: false }, 387: ) 388: if (trackingCode === 0 && trackingBranch.trim()) { 389: return trackingBranch.trim() 390: } 391: const { stdout: remoteRefs, code: remoteCode } = await execFileNoThrow( 392: gitExe(), 393: ['remote', 'show', 'origin', '--', 'HEAD'], 394: { preserveOutputOnError: false }, 395: ) 396: if (remoteCode === 0) { 397: const match = remoteRefs.match(/HEAD branch: (\S+)/) 398: if (match && match[1]) { 399: return `origin/${match[1]}` 400: } 401: } 402: const candidates = ['origin/main', 'origin/staging', 'origin/master'] 403: for (const candidate of candidates) { 404: const { code } = await execFileNoThrow( 405: gitExe(), 406: ['rev-parse', '--verify', candidate], 407: { preserveOutputOnError: false }, 408: ) 409: if (code === 0) { 410: return candidate 411: } 412: } 413: return null 414: } 415: function isShallowClone(): Promise<boolean> { 416: return isShallowCloneFs() 417: } 418: async function captureUntrackedFiles(): Promise< 419: Array<{ path: string; content: string }> 420: > { 421: const { stdout, code } = await execFileNoThrow( 422: gitExe(), 423: ['ls-files', '--others', '--exclude-standard'], 424: { preserveOutputOnError: false }, 425: ) 426: const trimmed = stdout.trim() 427: if (code !== 0 || !trimmed) { 428: return [] 429: } 430: const files = trimmed.split('\n').filter(Boolean) 431: const result: Array<{ path: string; content: string }> = [] 432: let totalSize = 0 433: for (const filePath of files) { 434: if (result.length >= MAX_FILE_COUNT) { 435: logForDebugging( 436: `Untracked file capture: reached max file count (${MAX_FILE_COUNT})`, 437: ) 438: break 439: } 440: if (hasBinaryExtension(filePath)) { 441: continue 442: } 443: try { 444: const stats = await stat(filePath) 445: const fileSize = stats.size 446: if (fileSize > MAX_FILE_SIZE_BYTES) { 447: logForDebugging( 448: `Untracked file capture: skipping ${filePath} (exceeds ${MAX_FILE_SIZE_BYTES} bytes)`, 449: ) 450: continue 451: } 452: if (totalSize + fileSize > MAX_TOTAL_SIZE_BYTES) { 453: logForDebugging( 454: `Untracked file capture: reached total size limit (${MAX_TOTAL_SIZE_BYTES} bytes)`, 455: ) 456: break 457: } 458: if (fileSize === 0) { 459: result.push({ path: filePath, content: '' }) 460: continue 461: } 462: // Binary sniff on up to SNIFF_BUFFER_SIZE bytes. Caps binary-file reads 463: // at SNIFF_BUFFER_SIZE even though MAX_FILE_SIZE_BYTES allows up to 500MB. 464: // If the file fits in the sniff buffer we reuse it as the content; for 465: // larger text files we fall back to readFile with encoding so the runtime 466: // decodes to a string without materializing a full-size Buffer in JS. 467: const sniffSize = Math.min(SNIFF_BUFFER_SIZE, fileSize) 468: const fd = await open(filePath, 'r') 469: try { 470: const sniffBuf = Buffer.alloc(sniffSize) 471: const { bytesRead } = await fd.read(sniffBuf, 0, sniffSize, 0) 472: const sniff = sniffBuf.subarray(0, bytesRead) 473: if (isBinaryContent(sniff)) { 474: continue 475: } 476: let content: string 477: if (fileSize <= sniffSize) { 478: content = sniff.toString('utf-8') 479: } else { 480: content = await readFile(filePath, 'utf-8') 481: } 482: result.push({ path: filePath, content }) 483: totalSize += fileSize 484: } finally { 485: await fd.close() 486: } 487: } catch (err) { 488: logForDebugging(`Failed to read untracked file ${filePath}: ${err}`) 489: } 490: } 491: return result 492: } 493: export async function preserveGitStateForIssue(): Promise<PreservedGitState | null> { 494: try { 495: const isGit = await getIsGit() 496: if (!isGit) { 497: return null 498: } 499: if (await isShallowClone()) { 500: logForDebugging('Shallow clone detected, using HEAD-only mode for issue') 501: const [{ stdout: patch }, untrackedFiles] = await Promise.all([ 502: execFileNoThrow(gitExe(), ['diff', 'HEAD']), 503: captureUntrackedFiles(), 504: ]) 505: return { 506: remote_base_sha: null, 507: remote_base: null, 508: patch: patch || '', 509: untracked_files: untrackedFiles, 510: format_patch: null, 511: head_sha: null, 512: branch_name: null, 513: } 514: } 515: // Find the best remote base 516: const remoteBase = await findRemoteBase() 517: if (!remoteBase) { 518: // No remote found - use HEAD-only mode 519: logForDebugging('No remote found, using HEAD-only mode for issue') 520: const [{ stdout: patch }, untrackedFiles] = await Promise.all([ 521: execFileNoThrow(gitExe(), ['diff', 'HEAD']), 522: captureUntrackedFiles(), 523: ]) 524: return { 525: remote_base_sha: null, 526: remote_base: null, 527: patch: patch || '', 528: untracked_files: untrackedFiles, 529: format_patch: null, 530: head_sha: null, 531: branch_name: null, 532: } 533: } 534: // Get the merge-base with remote 535: const { stdout: mergeBase, code: mergeBaseCode } = await execFileNoThrow( 536: gitExe(), 537: ['merge-base', 'HEAD', remoteBase], 538: { preserveOutputOnError: false }, 539: ) 540: if (mergeBaseCode !== 0 || !mergeBase.trim()) { 541: logForDebugging('Merge-base failed, using HEAD-only mode for issue') 542: const [{ stdout: patch }, untrackedFiles] = await Promise.all([ 543: execFileNoThrow(gitExe(), ['diff', 'HEAD']), 544: captureUntrackedFiles(), 545: ]) 546: return { 547: remote_base_sha: null, 548: remote_base: null, 549: patch: patch || '', 550: untracked_files: untrackedFiles, 551: format_patch: null, 552: head_sha: null, 553: branch_name: null, 554: } 555: } 556: const remoteBaseSha = mergeBase.trim() 557: // All 5 commands below depend only on remoteBaseSha — run them in parallel. 558: // ~5×90ms serial → ~90ms parallel on Bun native (used by /issue and /share). 559: const [ 560: { stdout: patch }, 561: untrackedFiles, 562: { stdout: formatPatchOut, code: formatPatchCode }, 563: { stdout: headSha }, 564: { stdout: branchName }, 565: ] = await Promise.all([ 566: // Patch from merge-base to current state (including staged changes) 567: execFileNoThrow(gitExe(), ['diff', remoteBaseSha]), 568: captureUntrackedFiles(), 569: execFileNoThrow(gitExe(), [ 570: 'format-patch', 571: `${remoteBaseSha}..HEAD`, 572: '--stdout', 573: ]), 574: execFileNoThrow(gitExe(), ['rev-parse', 'HEAD']), 575: execFileNoThrow(gitExe(), ['rev-parse', '--abbrev-ref', 'HEAD']), 576: ]) 577: let formatPatch: string | null = null 578: if (formatPatchCode === 0 && formatPatchOut && formatPatchOut.trim()) { 579: formatPatch = formatPatchOut 580: } 581: const trimmedBranch = branchName?.trim() 582: return { 583: remote_base_sha: remoteBaseSha, 584: remote_base: remoteBase, 585: patch: patch || '', 586: untracked_files: untrackedFiles, 587: format_patch: formatPatch, 588: head_sha: headSha?.trim() || null, 589: branch_name: 590: trimmedBranch && trimmedBranch !== 'HEAD' ? trimmedBranch : null, 591: } 592: } catch (err) { 593: logError(err) 594: return null 595: } 596: } 597: function isLocalHost(host: string): boolean { 598: const hostWithoutPort = host.split(':')[0] ?? '' 599: return ( 600: hostWithoutPort === 'localhost' || 601: /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostWithoutPort) 602: ) 603: } 604: export function isCurrentDirectoryBareGitRepo(): boolean { 605: const fs = getFsImplementation() 606: const cwd = getCwd() 607: const gitPath = join(cwd, '.git') 608: try { 609: const stats = fs.statSync(gitPath) 610: if (stats.isFile()) { 611: return false 612: } 613: if (stats.isDirectory()) { 614: const gitHeadPath = join(gitPath, 'HEAD') 615: try { 616: if (fs.statSync(gitHeadPath).isFile()) { 617: return false 618: } 619: } catch { 620: } 621: } 622: } catch { 623: } 624: try { 625: if (fs.statSync(join(cwd, 'HEAD')).isFile()) return true 626: } catch { 627: } 628: try { 629: if (fs.statSync(join(cwd, 'objects')).isDirectory()) return true 630: } catch { 631: } 632: try { 633: if (fs.statSync(join(cwd, 'refs')).isDirectory()) return true 634: } catch { 635: } 636: return false 637: }

File: src/utils/gitDiff.ts

typescript 1: import type { StructuredPatchHunk } from 'diff' 2: import { access, readFile } from 'fs/promises' 3: import { dirname, join, relative, sep } from 'path' 4: import { getCwd } from './cwd.js' 5: import { getCachedRepository } from './detectRepository.js' 6: import { execFileNoThrow, execFileNoThrowWithCwd } from './execFileNoThrow.js' 7: import { isFileWithinReadSizeLimit } from './file.js' 8: import { 9: findGitRoot, 10: getDefaultBranch, 11: getGitDir, 12: getIsGit, 13: gitExe, 14: } from './git.js' 15: export type GitDiffStats = { 16: filesCount: number 17: linesAdded: number 18: linesRemoved: number 19: } 20: export type PerFileStats = { 21: added: number 22: removed: number 23: isBinary: boolean 24: isUntracked?: boolean 25: } 26: export type GitDiffResult = { 27: stats: GitDiffStats 28: perFileStats: Map<string, PerFileStats> 29: hunks: Map<string, StructuredPatchHunk[]> 30: } 31: const GIT_TIMEOUT_MS = 5000 32: const MAX_FILES = 50 33: const MAX_DIFF_SIZE_BYTES = 1_000_000 34: const MAX_LINES_PER_FILE = 400 35: const MAX_FILES_FOR_DETAILS = 500 36: export async function fetchGitDiff(): Promise<GitDiffResult | null> { 37: const isGit = await getIsGit() 38: if (!isGit) return null 39: if (await isInTransientGitState()) { 40: return null 41: } 42: const { stdout: shortstatOut, code: shortstatCode } = await execFileNoThrow( 43: gitExe(), 44: ['--no-optional-locks', 'diff', 'HEAD', '--shortstat'], 45: { timeout: GIT_TIMEOUT_MS, preserveOutputOnError: false }, 46: ) 47: if (shortstatCode === 0) { 48: const quickStats = parseShortstat(shortstatOut) 49: if (quickStats && quickStats.filesCount > MAX_FILES_FOR_DETAILS) { 50: return { 51: stats: quickStats, 52: perFileStats: new Map(), 53: hunks: new Map(), 54: } 55: } 56: } 57: const { stdout: numstatOut, code: numstatCode } = await execFileNoThrow( 58: gitExe(), 59: ['--no-optional-locks', 'diff', 'HEAD', '--numstat'], 60: { timeout: GIT_TIMEOUT_MS, preserveOutputOnError: false }, 61: ) 62: if (numstatCode !== 0) return null 63: const { stats, perFileStats } = parseGitNumstat(numstatOut) 64: const remainingSlots = MAX_FILES - perFileStats.size 65: if (remainingSlots > 0) { 66: const untrackedStats = await fetchUntrackedFiles(remainingSlots) 67: if (untrackedStats) { 68: stats.filesCount += untrackedStats.size 69: for (const [path, fileStats] of untrackedStats) { 70: perFileStats.set(path, fileStats) 71: } 72: } 73: } 74: return { stats, perFileStats, hunks: new Map() } 75: } 76: export async function fetchGitDiffHunks(): Promise< 77: Map<string, StructuredPatchHunk[]> 78: > { 79: const isGit = await getIsGit() 80: if (!isGit) return new Map() 81: if (await isInTransientGitState()) { 82: return new Map() 83: } 84: const { stdout: diffOut, code: diffCode } = await execFileNoThrow( 85: gitExe(), 86: ['--no-optional-locks', 'diff', 'HEAD'], 87: { timeout: GIT_TIMEOUT_MS, preserveOutputOnError: false }, 88: ) 89: if (diffCode !== 0) { 90: return new Map() 91: } 92: return parseGitDiff(diffOut) 93: } 94: export type NumstatResult = { 95: stats: GitDiffStats 96: perFileStats: Map<string, PerFileStats> 97: } 98: export function parseGitNumstat(stdout: string): NumstatResult { 99: const lines = stdout.trim().split('\n').filter(Boolean) 100: let added = 0 101: let removed = 0 102: let validFileCount = 0 103: const perFileStats = new Map<string, PerFileStats>() 104: for (const line of lines) { 105: const parts = line.split('\t') 106: if (parts.length < 3) continue 107: validFileCount++ 108: const addStr = parts[0] 109: const remStr = parts[1] 110: const filePath = parts.slice(2).join('\t') 111: const isBinary = addStr === '-' || remStr === '-' 112: const fileAdded = isBinary ? 0 : parseInt(addStr ?? '0', 10) || 0 113: const fileRemoved = isBinary ? 0 : parseInt(remStr ?? '0', 10) || 0 114: added += fileAdded 115: removed += fileRemoved 116: if (perFileStats.size < MAX_FILES) { 117: perFileStats.set(filePath, { 118: added: fileAdded, 119: removed: fileRemoved, 120: isBinary, 121: }) 122: } 123: } 124: return { 125: stats: { 126: filesCount: validFileCount, 127: linesAdded: added, 128: linesRemoved: removed, 129: }, 130: perFileStats, 131: } 132: } 133: export function parseGitDiff( 134: stdout: string, 135: ): Map<string, StructuredPatchHunk[]> { 136: const result = new Map<string, StructuredPatchHunk[]>() 137: if (!stdout.trim()) return result 138: const fileDiffs = stdout.split(/^diff --git /m).filter(Boolean) 139: for (const fileDiff of fileDiffs) { 140: if (result.size >= MAX_FILES) break 141: if (fileDiff.length > MAX_DIFF_SIZE_BYTES) { 142: continue 143: } 144: const lines = fileDiff.split('\n') 145: const headerMatch = lines[0]?.match(/^a\/(.+?) b\/(.+)$/) 146: if (!headerMatch) continue 147: const filePath = headerMatch[2] ?? headerMatch[1] ?? '' 148: // Find and parse hunks 149: const fileHunks: StructuredPatchHunk[] = [] 150: let currentHunk: StructuredPatchHunk | null = null 151: let lineCount = 0 152: for (let i = 1; i < lines.length; i++) { 153: const line = lines[i] ?? '' 154: // StructuredPatchHunk header: @@ -oldStart,oldLines +newStart,newLines @@ 155: const hunkMatch = line.match( 156: /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/, 157: ) 158: if (hunkMatch) { 159: if (currentHunk) { 160: fileHunks.push(currentHunk) 161: } 162: currentHunk = { 163: oldStart: parseInt(hunkMatch[1] ?? '0', 10), 164: oldLines: parseInt(hunkMatch[2] ?? '1', 10), 165: newStart: parseInt(hunkMatch[3] ?? '0', 10), 166: newLines: parseInt(hunkMatch[4] ?? '1', 10), 167: lines: [], 168: } 169: continue 170: } 171: if ( 172: line.startsWith('index ') || 173: line.startsWith('---') || 174: line.startsWith('+++') || 175: line.startsWith('new file') || 176: line.startsWith('deleted file') || 177: line.startsWith('old mode') || 178: line.startsWith('new mode') || 179: line.startsWith('Binary files') 180: ) { 181: continue 182: } 183: if ( 184: currentHunk && 185: (line.startsWith('+') || 186: line.startsWith('-') || 187: line.startsWith(' ') || 188: line === '') 189: ) { 190: // Stop adding lines once we hit the limit 191: if (lineCount >= MAX_LINES_PER_FILE) { 192: continue 193: } 194: // Force a flat string copy to break V8 sliced string references. 195: // When split() creates lines, V8 creates "sliced strings" that reference 196: // the parent. This keeps the entire parent string (~MBs) alive as long as 197: // any line is retained. Using '' + line forces a new flat string allocation, 198: // unlike slice(0) which V8 may optimize to return the same reference. 199: currentHunk.lines.push('' + line) 200: lineCount++ 201: } 202: } 203: // Don't forget the last hunk 204: if (currentHunk) { 205: fileHunks.push(currentHunk) 206: } 207: if (fileHunks.length > 0) { 208: result.set(filePath, fileHunks) 209: } 210: } 211: return result 212: } 213: async function isInTransientGitState(): Promise<boolean> { 214: const gitDir = await getGitDir(getCwd()) 215: if (!gitDir) return false 216: const transientFiles = [ 217: 'MERGE_HEAD', 218: 'REBASE_HEAD', 219: 'CHERRY_PICK_HEAD', 220: 'REVERT_HEAD', 221: ] 222: const results = await Promise.all( 223: transientFiles.map(file => 224: access(join(gitDir, file)) 225: .then(() => true) 226: .catch(() => false), 227: ), 228: ) 229: return results.some(Boolean) 230: } 231: async function fetchUntrackedFiles( 232: maxFiles: number, 233: ): Promise<Map<string, PerFileStats> | null> { 234: const { stdout, code } = await execFileNoThrow( 235: gitExe(), 236: ['--no-optional-locks', 'ls-files', '--others', '--exclude-standard'], 237: { timeout: GIT_TIMEOUT_MS, preserveOutputOnError: false }, 238: ) 239: if (code !== 0 || !stdout.trim()) return null 240: const untrackedPaths = stdout.trim().split('\n').filter(Boolean) 241: if (untrackedPaths.length === 0) return null 242: const perFileStats = new Map<string, PerFileStats>() 243: for (const filePath of untrackedPaths.slice(0, maxFiles)) { 244: perFileStats.set(filePath, { 245: added: 0, 246: removed: 0, 247: isBinary: false, 248: isUntracked: true, 249: }) 250: } 251: return perFileStats 252: } 253: export function parseShortstat(stdout: string): GitDiffStats | null { 254: const match = stdout.match( 255: /(\d+)\s+files?\s+changed(?:,\s+(\d+)\s+insertions?\(\+\))?(?:,\s+(\d+)\s+deletions?\(-\))?/, 256: ) 257: if (!match) return null 258: return { 259: filesCount: parseInt(match[1] ?? '0', 10), 260: linesAdded: parseInt(match[2] ?? '0', 10), 261: linesRemoved: parseInt(match[3] ?? '0', 10), 262: } 263: } 264: const SINGLE_FILE_DIFF_TIMEOUT_MS = 3000 265: export type ToolUseDiff = { 266: filename: string 267: status: 'modified' | 'added' 268: additions: number 269: deletions: number 270: changes: number 271: patch: string 272: repository: string | null 273: } 274: export async function fetchSingleFileGitDiff( 275: absoluteFilePath: string, 276: ): Promise<ToolUseDiff | null> { 277: const gitRoot = findGitRoot(dirname(absoluteFilePath)) 278: if (!gitRoot) return null 279: const gitPath = relative(gitRoot, absoluteFilePath).split(sep).join('/') 280: const repository = getCachedRepository() 281: const { code: lsFilesCode } = await execFileNoThrowWithCwd( 282: gitExe(), 283: ['--no-optional-locks', 'ls-files', '--error-unmatch', gitPath], 284: { cwd: gitRoot, timeout: SINGLE_FILE_DIFF_TIMEOUT_MS }, 285: ) 286: if (lsFilesCode === 0) { 287: const diffRef = await getDiffRef(gitRoot) 288: const { stdout, code } = await execFileNoThrowWithCwd( 289: gitExe(), 290: ['--no-optional-locks', 'diff', diffRef, '--', gitPath], 291: { cwd: gitRoot, timeout: SINGLE_FILE_DIFF_TIMEOUT_MS }, 292: ) 293: if (code !== 0) return null 294: if (!stdout) return null 295: return { 296: ...parseRawDiffToToolUseDiff(gitPath, stdout, 'modified'), 297: repository, 298: } 299: } 300: const syntheticDiff = await generateSyntheticDiff(gitPath, absoluteFilePath) 301: if (!syntheticDiff) return null 302: return { ...syntheticDiff, repository } 303: } 304: function parseRawDiffToToolUseDiff( 305: filename: string, 306: rawDiff: string, 307: status: 'modified' | 'added', 308: ): Omit<ToolUseDiff, 'repository'> { 309: const lines = rawDiff.split('\n') 310: const patchLines: string[] = [] 311: let inHunks = false 312: let additions = 0 313: let deletions = 0 314: for (const line of lines) { 315: if (line.startsWith('@@')) { 316: inHunks = true 317: } 318: if (inHunks) { 319: patchLines.push(line) 320: if (line.startsWith('+') && !line.startsWith('+++')) { 321: additions++ 322: } else if (line.startsWith('-') && !line.startsWith('---')) { 323: deletions++ 324: } 325: } 326: } 327: return { 328: filename, 329: status, 330: additions, 331: deletions, 332: changes: additions + deletions, 333: patch: patchLines.join('\n'), 334: } 335: } 336: async function getDiffRef(gitRoot: string): Promise<string> { 337: const baseBranch = 338: process.env.CLAUDE_CODE_BASE_REF || (await getDefaultBranch()) 339: const { stdout, code } = await execFileNoThrowWithCwd( 340: gitExe(), 341: ['--no-optional-locks', 'merge-base', 'HEAD', baseBranch], 342: { cwd: gitRoot, timeout: SINGLE_FILE_DIFF_TIMEOUT_MS }, 343: ) 344: if (code === 0 && stdout.trim()) { 345: return stdout.trim() 346: } 347: return 'HEAD' 348: } 349: async function generateSyntheticDiff( 350: gitPath: string, 351: absoluteFilePath: string, 352: ): Promise<Omit<ToolUseDiff, 'repository'> | null> { 353: try { 354: if (!isFileWithinReadSizeLimit(absoluteFilePath, MAX_DIFF_SIZE_BYTES)) { 355: return null 356: } 357: const content = await readFile(absoluteFilePath, 'utf-8') 358: const lines = content.split('\n') 359: if (lines.length > 0 && lines.at(-1) === '') { 360: lines.pop() 361: } 362: const lineCount = lines.length 363: const addedLines = lines.map(line => `+${line}`).join('\n') 364: const patch = `@@ -0,0 +1,${lineCount} @@\n${addedLines}` 365: return { 366: filename: gitPath, 367: status: 'added', 368: additions: lineCount, 369: deletions: 0, 370: changes: lineCount, 371: patch, 372: } 373: } catch { 374: return null 375: } 376: }

File: src/utils/githubRepoPathMapping.ts

typescript 1: import { realpath } from 'fs/promises' 2: import { getOriginalCwd } from '../bootstrap/state.js' 3: import { getGlobalConfig, saveGlobalConfig } from './config.js' 4: import { logForDebugging } from './debug.js' 5: import { 6: detectCurrentRepository, 7: parseGitHubRepository, 8: } from './detectRepository.js' 9: import { pathExists } from './file.js' 10: import { getRemoteUrlForDir } from './git/gitFilesystem.js' 11: import { findGitRoot } from './git.js' 12: export async function updateGithubRepoPathMapping(): Promise<void> { 13: try { 14: const repo = await detectCurrentRepository() 15: if (!repo) { 16: logForDebugging( 17: 'Not in a GitHub repository, skipping path mapping update', 18: ) 19: return 20: } 21: const cwd = getOriginalCwd() 22: const gitRoot = findGitRoot(cwd) 23: const basePath = gitRoot ?? cwd 24: let currentPath: string 25: try { 26: currentPath = (await realpath(basePath)).normalize('NFC') 27: } catch { 28: currentPath = basePath 29: } 30: const repoKey = repo.toLowerCase() 31: const config = getGlobalConfig() 32: const existingPaths = config.githubRepoPaths?.[repoKey] ?? [] 33: if (existingPaths[0] === currentPath) { 34: logForDebugging(`Path ${currentPath} already tracked for repo ${repoKey}`) 35: return 36: } 37: const withoutCurrent = existingPaths.filter(p => p !== currentPath) 38: const updatedPaths = [currentPath, ...withoutCurrent] 39: saveGlobalConfig(current => ({ 40: ...current, 41: githubRepoPaths: { 42: ...current.githubRepoPaths, 43: [repoKey]: updatedPaths, 44: }, 45: })) 46: logForDebugging(`Added ${currentPath} to tracked paths for repo ${repoKey}`) 47: } catch (error) { 48: logForDebugging(`Error updating repo path mapping: ${error}`) 49: } 50: } 51: export function getKnownPathsForRepo(repo: string): string[] { 52: const config = getGlobalConfig() 53: const repoKey = repo.toLowerCase() 54: return config.githubRepoPaths?.[repoKey] ?? [] 55: } 56: export async function filterExistingPaths(paths: string[]): Promise<string[]> { 57: const results = await Promise.all(paths.map(pathExists)) 58: return paths.filter((_, i) => results[i]) 59: } 60: export async function validateRepoAtPath( 61: path: string, 62: expectedRepo: string, 63: ): Promise<boolean> { 64: try { 65: const remoteUrl = await getRemoteUrlForDir(path) 66: if (!remoteUrl) { 67: return false 68: } 69: const actualRepo = parseGitHubRepository(remoteUrl) 70: if (!actualRepo) { 71: return false 72: } 73: return actualRepo.toLowerCase() === expectedRepo.toLowerCase() 74: } catch { 75: return false 76: } 77: } 78: export function removePathFromRepo(repo: string, pathToRemove: string): void { 79: const config = getGlobalConfig() 80: const repoKey = repo.toLowerCase() 81: const existingPaths = config.githubRepoPaths?.[repoKey] ?? [] 82: const updatedPaths = existingPaths.filter(path => path !== pathToRemove) 83: if (updatedPaths.length === existingPaths.length) { 84: return 85: } 86: const updatedMapping = { ...config.githubRepoPaths } 87: if (updatedPaths.length === 0) { 88: delete updatedMapping[repoKey] 89: } else { 90: updatedMapping[repoKey] = updatedPaths 91: } 92: saveGlobalConfig(current => ({ 93: ...current, 94: githubRepoPaths: updatedMapping, 95: })) 96: logForDebugging( 97: `Removed ${pathToRemove} from tracked paths for repo ${repoKey}`, 98: ) 99: }

File: src/utils/gitSettings.ts

typescript 1: import { isEnvDefinedFalsy, isEnvTruthy } from './envUtils.js' 2: import { getInitialSettings } from './settings/settings.js' 3: export function shouldIncludeGitInstructions(): boolean { 4: const envVal = process.env.CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS 5: if (isEnvTruthy(envVal)) return false 6: if (isEnvDefinedFalsy(envVal)) return true 7: return getInitialSettings().includeGitInstructions ?? true 8: }

File: src/utils/glob.ts

typescript 1: import { basename, dirname, isAbsolute, join, sep } from 'path' 2: import type { ToolPermissionContext } from '../Tool.js' 3: import { isEnvTruthy } from './envUtils.js' 4: import { 5: getFileReadIgnorePatterns, 6: normalizePatternsToPath, 7: } from './permissions/filesystem.js' 8: import { getPlatform } from './platform.js' 9: import { getGlobExclusionsForPluginCache } from './plugins/orphanedPluginFilter.js' 10: import { ripGrep } from './ripgrep.js' 11: export function extractGlobBaseDirectory(pattern: string): { 12: baseDir: string 13: relativePattern: string 14: } { 15: const globChars = /[*?[{]/ 16: const match = pattern.match(globChars) 17: if (!match || match.index === undefined) { 18: const dir = dirname(pattern) 19: const file = basename(pattern) 20: return { baseDir: dir, relativePattern: file } 21: } 22: const staticPrefix = pattern.slice(0, match.index) 23: const lastSepIndex = Math.max( 24: staticPrefix.lastIndexOf('/'), 25: staticPrefix.lastIndexOf(sep), 26: ) 27: if (lastSepIndex === -1) { 28: return { baseDir: '', relativePattern: pattern } 29: } 30: let baseDir = staticPrefix.slice(0, lastSepIndex) 31: const relativePattern = pattern.slice(lastSepIndex + 1) 32: // Handle root directory patterns (e.g., /*.txt on Unix or C:/*.txt on Windows) 33: // When lastSepIndex is 0, baseDir is empty but we need to use '/' as the root 34: if (baseDir === '' && lastSepIndex === 0) { 35: baseDir = '/' 36: } 37: // Handle Windows drive root paths (e.g., C:/*.txt) 38: // 'C:' means "current directory on drive C" (relative), not root 39: // We need 'C:/' or 'C:\' for the actual drive root 40: if (getPlatform() === 'windows' && /^[A-Za-z]:$/.test(baseDir)) { 41: baseDir = baseDir + sep 42: } 43: return { baseDir, relativePattern } 44: } 45: export async function glob( 46: filePattern: string, 47: cwd: string, 48: { limit, offset }: { limit: number; offset: number }, 49: abortSignal: AbortSignal, 50: toolPermissionContext: ToolPermissionContext, 51: ): Promise<{ files: string[]; truncated: boolean }> { 52: let searchDir = cwd 53: let searchPattern = filePattern 54: if (isAbsolute(filePattern)) { 55: const { baseDir, relativePattern } = extractGlobBaseDirectory(filePattern) 56: if (baseDir) { 57: searchDir = baseDir 58: searchPattern = relativePattern 59: } 60: } 61: const ignorePatterns = normalizePatternsToPath( 62: getFileReadIgnorePatterns(toolPermissionContext), 63: searchDir, 64: ) 65: const noIgnore = isEnvTruthy(process.env.CLAUDE_CODE_GLOB_NO_IGNORE || 'true') 66: const hidden = isEnvTruthy(process.env.CLAUDE_CODE_GLOB_HIDDEN || 'true') 67: const args = [ 68: '--files', 69: '--glob', 70: searchPattern, 71: '--sort=modified', 72: ...(noIgnore ? ['--no-ignore'] : []), 73: ...(hidden ? ['--hidden'] : []), 74: ] 75: for (const pattern of ignorePatterns) { 76: args.push('--glob', `!${pattern}`) 77: } 78: for (const exclusion of await getGlobExclusionsForPluginCache(searchDir)) { 79: args.push('--glob', exclusion) 80: } 81: const allPaths = await ripGrep(args, searchDir, abortSignal) 82: const absolutePaths = allPaths.map(p => 83: isAbsolute(p) ? p : join(searchDir, p), 84: ) 85: const truncated = absolutePaths.length > offset + limit 86: const files = absolutePaths.slice(offset, offset + limit) 87: return { files, truncated } 88: }

File: src/utils/gracefulShutdown.ts

typescript 1: import chalk from 'chalk' 2: import { writeSync } from 'fs' 3: import memoize from 'lodash-es/memoize.js' 4: import { onExit } from 'signal-exit' 5: import type { ExitReason } from 'src/entrypoints/agentSdkTypes.js' 6: import { 7: getIsInteractive, 8: getIsScrollDraining, 9: getLastMainRequestId, 10: getSessionId, 11: isSessionPersistenceDisabled, 12: } from '../bootstrap/state.js' 13: import instances from '../ink/instances.js' 14: import { 15: DISABLE_KITTY_KEYBOARD, 16: DISABLE_MODIFY_OTHER_KEYS, 17: } from '../ink/termio/csi.js' 18: import { 19: DBP, 20: DFE, 21: DISABLE_MOUSE_TRACKING, 22: EXIT_ALT_SCREEN, 23: SHOW_CURSOR, 24: } from '../ink/termio/dec.js' 25: import { 26: CLEAR_ITERM2_PROGRESS, 27: CLEAR_TAB_STATUS, 28: CLEAR_TERMINAL_TITLE, 29: supportsTabStatus, 30: wrapForMultiplexer, 31: } from '../ink/termio/osc.js' 32: import { shutdownDatadog } from '../services/analytics/datadog.js' 33: import { shutdown1PEventLogging } from '../services/analytics/firstPartyEventLogger.js' 34: import { 35: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 36: logEvent, 37: } from '../services/analytics/index.js' 38: import type { AppState } from '../state/AppState.js' 39: import { runCleanupFunctions } from './cleanupRegistry.js' 40: import { logForDebugging } from './debug.js' 41: import { logForDiagnosticsNoPII } from './diagLogs.js' 42: import { isEnvTruthy } from './envUtils.js' 43: import { getCurrentSessionTitle, sessionIdExists } from './sessionStorage.js' 44: import { sleep } from './sleep.js' 45: import { profileReport } from './startupProfiler.js' 46: function cleanupTerminalModes(): void { 47: if (!process.stdout.isTTY) { 48: return 49: } 50: try { 51: writeSync(1, DISABLE_MOUSE_TRACKING) 52: const inst = instances.get(process.stdout) 53: if (inst?.isAltScreenActive) { 54: try { 55: inst.unmount() 56: } catch { 57: writeSync(1, EXIT_ALT_SCREEN) 58: } 59: } 60: inst?.drainStdin() 61: inst?.detachForShutdown() 62: writeSync(1, DISABLE_MODIFY_OTHER_KEYS) 63: writeSync(1, DISABLE_KITTY_KEYBOARD) 64: writeSync(1, DFE) 65: writeSync(1, DBP) 66: writeSync(1, SHOW_CURSOR) 67: writeSync(1, CLEAR_ITERM2_PROGRESS) 68: if (supportsTabStatus()) writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)) 69: if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { 70: if (process.platform === 'win32') { 71: process.title = '' 72: } else { 73: writeSync(1, CLEAR_TERMINAL_TITLE) 74: } 75: } 76: } catch { 77: // Terminal may already be gone (e.g., SIGHUP after terminal close). 78: // Ignore write errors since we're exiting anyway. 79: } 80: } 81: let resumeHintPrinted = false 82: function printResumeHint(): void { 83: if (resumeHintPrinted) { 84: return 85: } 86: if ( 87: process.stdout.isTTY && 88: getIsInteractive() && 89: !isSessionPersistenceDisabled() 90: ) { 91: try { 92: const sessionId = getSessionId() 93: if (!sessionIdExists(sessionId)) { 94: return 95: } 96: const customTitle = getCurrentSessionTitle(sessionId) 97: let resumeArg: string 98: if (customTitle) { 99: const escaped = customTitle.replace(/\\/g, '\\\\').replace(/"/g, '\\"') 100: resumeArg = `"${escaped}"` 101: } else { 102: resumeArg = sessionId 103: } 104: writeSync( 105: 1, 106: chalk.dim( 107: `\nResume this session with:\nclaude --resume ${resumeArg}\n`, 108: ), 109: ) 110: resumeHintPrinted = true 111: } catch { 112: // Ignore write errors 113: } 114: } 115: } 116: /* eslint-enable custom-rules/no-sync-fs */ 117: /** 118: * Force process exit, handling the case where the terminal is gone. 119: * When the terminal/PTY is closed (e.g., SIGHUP), process.exit() can throw 120: * EIO errors because Bun tries to flush stdout to a dead file descriptor. 121: * In that case, fall back to SIGKILL which always works. 122: */ 123: function forceExit(exitCode: number): never { 124: // Clear failsafe timer since we're exiting now 125: if (failsafeTimer !== undefined) { 126: clearTimeout(failsafeTimer) 127: failsafeTimer = undefined 128: } 129: // Drain stdin LAST, right before exit. cleanupTerminalModes() sent 130: // DISABLE_MOUSE_TRACKING early, but the terminal round-trip plus any 131: // events already in flight means bytes can arrive during the seconds 132: // of async cleanup between then and now. Draining here catches them. 133: // Use the Ink class method (not the standalone drainStdin()) so we 134: // drain the instance's stdin — when process.stdin is piped, 135: // getStdinOverride() opens /dev/tty as the real input stream and the 136: // class method knows about it; the standalone function defaults to 137: // process.stdin which would early-return on isTTY=false. 138: try { 139: instances.get(process.stdout)?.drainStdin() 140: } catch { 141: // Terminal may be gone (SIGHUP). Ignore — we are about to exit. 142: } 143: try { 144: process.exit(exitCode) 145: } catch (e) { 146: // process.exit() threw. In tests, it's mocked to throw - re-throw so test sees it. 147: // In production, it's likely EIO from dead terminal - use SIGKILL. 148: if ((process.env.NODE_ENV as string) === 'test') { 149: throw e 150: } 151: // Fall back to SIGKILL which doesn't try to flush anything. 152: process.kill(process.pid, 'SIGKILL') 153: } 154: // In tests, process.exit may be mocked to return instead of exiting. 155: // In production, we should never reach here. 156: if ((process.env.NODE_ENV as string) !== 'test') { 157: throw new Error('unreachable') 158: } 159: // TypeScript trick: cast to never since we know this only happens in tests 160: // where the mock returns instead of exiting 161: return undefined as never 162: } 163: /** 164: * Set up global signal handlers for graceful shutdown 165: */ 166: export const setupGracefulShutdown = memoize(() => { 167: // Work around a Bun bug where process.removeListener(sig, fn) resets the 168: // kernel sigaction for that signal even when other JS listeners remain — 169: // the signal then falls back to its default action (terminate) and our 170: // process.on('SIGTERM') handler never runs. 171: // 172: // Trigger: any short-lived signal-exit v4 subscriber (e.g. execa per child 173: // process, or an Ink instance that unmounts). When its unsubscribe runs and 174: // it was the last v4 subscriber, v4.unload() calls removeListener on every 175: // signal in its list (SIGTERM, SIGINT, SIGHUP, …), tripping the Bun bug and 176: // nuking our handlers at the kernel level. 177: // 178: // Fix: pin signal-exit v4 loaded by registering a no-op onExit callback that 179: // is never unsubscribed. This keeps v4's internal emitter count > 0 so 180: // unload() never runs and removeListener is never called. Harmless under 181: // Node.js — the pin also ensures signal-exit's process.exit hook stays 182: // active for Ink cleanup. 183: onExit(() => {}) 184: process.on('SIGINT', () => { 185: // In print mode, print.ts registers its own SIGINT handler that aborts 186: // the in-flight query and calls gracefulShutdown(0); skip here to 187: // avoid racing with it. Only check print mode — other non-interactive 188: // sessions (--sdk-url, --init-only, non-TTY) don't register their own 189: // SIGINT handler and need gracefulShutdown to run. 190: if (process.argv.includes('-p') || process.argv.includes('--print')) { 191: return 192: } 193: logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGINT' }) 194: void gracefulShutdown(0) 195: }) 196: process.on('SIGTERM', () => { 197: logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGTERM' }) 198: void gracefulShutdown(143) // Exit code 143 (128 + 15) for SIGTERM 199: }) 200: if (process.platform !== 'win32') { 201: process.on('SIGHUP', () => { 202: logForDiagnosticsNoPII('info', 'shutdown_signal', { signal: 'SIGHUP' }) 203: void gracefulShutdown(129) // Exit code 129 (128 + 1) for SIGHUP 204: }) 205: // Detect orphaned process when terminal closes without delivering SIGHUP. 206: // macOS revokes TTY file descriptors instead of signaling, leaving the 207: // process alive but unable to read/write. Periodically check stdin validity. 208: if (process.stdin.isTTY) { 209: orphanCheckInterval = setInterval(() => { 210: // Skip during scroll drain — even a cheap check consumes an event 211: // loop tick that scroll frames need. 30s interval → missing one is fine. 212: if (getIsScrollDraining()) return 213: // process.stdout.writable becomes false when the TTY is revoked 214: if (!process.stdout.writable || !process.stdin.readable) { 215: clearInterval(orphanCheckInterval) 216: logForDiagnosticsNoPII('info', 'shutdown_signal', { 217: signal: 'orphan_detected', 218: }) 219: void gracefulShutdown(129) 220: } 221: }, 30_000) // Check every 30 seconds 222: orphanCheckInterval.unref() // Don't keep process alive just for this check 223: } 224: } 225: // Log uncaught exceptions for container observability and analytics 226: // Error names (e.g., "TypeError") are not sensitive - safe to log 227: process.on('uncaughtException', error => { 228: logForDiagnosticsNoPII('error', 'uncaught_exception', { 229: error_name: error.name, 230: error_message: error.message.slice(0, 2000), 231: }) 232: logEvent('tengu_uncaught_exception', { 233: error_name: 234: error.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 235: }) 236: }) 237: process.on('unhandledRejection', reason => { 238: const errorName = 239: reason instanceof Error 240: ? reason.name 241: : typeof reason === 'string' 242: ? 'string' 243: : 'unknown' 244: const errorInfo = 245: reason instanceof Error 246: ? { 247: error_name: reason.name, 248: error_message: reason.message.slice(0, 2000), 249: error_stack: reason.stack?.slice(0, 4000), 250: } 251: : { error_message: String(reason).slice(0, 2000) } 252: logForDiagnosticsNoPII('error', 'unhandled_rejection', errorInfo) 253: logEvent('tengu_unhandled_rejection', { 254: error_name: 255: errorName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 256: }) 257: }) 258: }) 259: export function gracefulShutdownSync( 260: exitCode = 0, 261: reason: ExitReason = 'other', 262: options?: { 263: getAppState?: () => AppState 264: setAppState?: (f: (prev: AppState) => AppState) => void 265: }, 266: ): void { 267: process.exitCode = exitCode 268: pendingShutdown = gracefulShutdown(exitCode, reason, options) 269: .catch(error => { 270: logForDebugging(`Graceful shutdown failed: ${error}`, { level: 'error' }) 271: cleanupTerminalModes() 272: printResumeHint() 273: forceExit(exitCode) 274: }) 275: .catch(() => {}) 276: } 277: let shutdownInProgress = false 278: let failsafeTimer: ReturnType<typeof setTimeout> | undefined 279: let orphanCheckInterval: ReturnType<typeof setInterval> | undefined 280: let pendingShutdown: Promise<void> | undefined 281: export function isShuttingDown(): boolean { 282: return shutdownInProgress 283: } 284: export function resetShutdownState(): void { 285: shutdownInProgress = false 286: resumeHintPrinted = false 287: if (failsafeTimer !== undefined) { 288: clearTimeout(failsafeTimer) 289: failsafeTimer = undefined 290: } 291: pendingShutdown = undefined 292: } 293: export function getPendingShutdownForTesting(): Promise<void> | undefined { 294: return pendingShutdown 295: } 296: export async function gracefulShutdown( 297: exitCode = 0, 298: reason: ExitReason = 'other', 299: options?: { 300: getAppState?: () => AppState 301: setAppState?: (f: (prev: AppState) => AppState) => void 302: finalMessage?: string 303: }, 304: ): Promise<void> { 305: if (shutdownInProgress) { 306: return 307: } 308: shutdownInProgress = true 309: const { executeSessionEndHooks, getSessionEndHookTimeoutMs } = await import( 310: './hooks.js' 311: ) 312: const sessionEndTimeoutMs = getSessionEndHookTimeoutMs() 313: failsafeTimer = setTimeout( 314: code => { 315: cleanupTerminalModes() 316: printResumeHint() 317: forceExit(code) 318: }, 319: Math.max(5000, sessionEndTimeoutMs + 3500), 320: exitCode, 321: ) 322: failsafeTimer.unref() 323: process.exitCode = exitCode 324: cleanupTerminalModes() 325: printResumeHint() 326: let cleanupTimeoutId: ReturnType<typeof setTimeout> | undefined 327: try { 328: const cleanupPromise = (async () => { 329: try { 330: await runCleanupFunctions() 331: } catch { 332: } 333: })() 334: await Promise.race([ 335: cleanupPromise, 336: new Promise((_, reject) => { 337: cleanupTimeoutId = setTimeout( 338: rej => rej(new CleanupTimeoutError()), 339: 2000, 340: reject, 341: ) 342: }), 343: ]) 344: clearTimeout(cleanupTimeoutId) 345: } catch { 346: clearTimeout(cleanupTimeoutId) 347: } 348: try { 349: await executeSessionEndHooks(reason, { 350: ...options, 351: signal: AbortSignal.timeout(sessionEndTimeoutMs), 352: timeoutMs: sessionEndTimeoutMs, 353: }) 354: } catch { 355: } 356: try { 357: profileReport() 358: } catch { 359: } 360: const lastRequestId = getLastMainRequestId() 361: if (lastRequestId) { 362: logEvent('tengu_cache_eviction_hint', { 363: scope: 364: 'session_end' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 365: last_request_id: 366: lastRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 367: }) 368: } 369: try { 370: await Promise.race([ 371: Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), 372: sleep(500), 373: ]) 374: } catch { 375: } 376: if (options?.finalMessage) { 377: try { 378: writeSync(2, options.finalMessage + '\n') 379: } catch { 380: } 381: } 382: forceExit(exitCode) 383: } 384: class CleanupTimeoutError extends Error { 385: constructor() { 386: super('Cleanup timeout') 387: } 388: }

File: src/utils/groupToolUses.ts

typescript 1: import type { BetaToolUseBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' 2: import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs' 3: import type { Tools } from '../Tool.js' 4: import type { 5: GroupedToolUseMessage, 6: NormalizedAssistantMessage, 7: NormalizedMessage, 8: NormalizedUserMessage, 9: ProgressMessage, 10: RenderableMessage, 11: } from '../types/message.js' 12: export type MessageWithoutProgress = Exclude<NormalizedMessage, ProgressMessage> 13: export type GroupingResult = { 14: messages: RenderableMessage[] 15: } 16: const GROUPING_CACHE = new WeakMap<Tools, Set<string>>() 17: function getToolsWithGrouping(tools: Tools): Set<string> { 18: let cached = GROUPING_CACHE.get(tools) 19: if (!cached) { 20: cached = new Set(tools.filter(t => t.renderGroupedToolUse).map(t => t.name)) 21: GROUPING_CACHE.set(tools, cached) 22: } 23: return cached 24: } 25: function getToolUseInfo( 26: msg: MessageWithoutProgress, 27: ): { messageId: string; toolUseId: string; toolName: string } | null { 28: if (msg.type === 'assistant' && msg.message.content[0]?.type === 'tool_use') { 29: const content = msg.message.content[0] 30: return { 31: messageId: msg.message.id, 32: toolUseId: content.id, 33: toolName: content.name, 34: } 35: } 36: return null 37: } 38: export function applyGrouping( 39: messages: MessageWithoutProgress[], 40: tools: Tools, 41: verbose: boolean = false, 42: ): GroupingResult { 43: if (verbose) { 44: return { 45: messages: messages, 46: } 47: } 48: const toolsWithGrouping = getToolsWithGrouping(tools) 49: const groups = new Map< 50: string, 51: NormalizedAssistantMessage<BetaToolUseBlock>[] 52: >() 53: for (const msg of messages) { 54: const info = getToolUseInfo(msg) 55: if (info && toolsWithGrouping.has(info.toolName)) { 56: const key = `${info.messageId}:${info.toolName}` 57: const group = groups.get(key) ?? [] 58: group.push(msg as NormalizedAssistantMessage<BetaToolUseBlock>) 59: groups.set(key, group) 60: } 61: } 62: const validGroups = new Map< 63: string, 64: NormalizedAssistantMessage<BetaToolUseBlock>[] 65: >() 66: const groupedToolUseIds = new Set<string>() 67: for (const [key, group] of groups) { 68: if (group.length >= 2) { 69: validGroups.set(key, group) 70: for (const msg of group) { 71: const info = getToolUseInfo(msg) 72: if (info) { 73: groupedToolUseIds.add(info.toolUseId) 74: } 75: } 76: } 77: } 78: const resultsByToolUseId = new Map<string, NormalizedUserMessage>() 79: for (const msg of messages) { 80: if (msg.type === 'user') { 81: for (const content of msg.message.content) { 82: if ( 83: content.type === 'tool_result' && 84: groupedToolUseIds.has(content.tool_use_id) 85: ) { 86: resultsByToolUseId.set(content.tool_use_id, msg) 87: } 88: } 89: } 90: } 91: const result: RenderableMessage[] = [] 92: const emittedGroups = new Set<string>() 93: for (const msg of messages) { 94: const info = getToolUseInfo(msg) 95: if (info) { 96: const key = `${info.messageId}:${info.toolName}` 97: const group = validGroups.get(key) 98: if (group) { 99: if (!emittedGroups.has(key)) { 100: emittedGroups.add(key) 101: const firstMsg = group[0]! 102: const results: NormalizedUserMessage[] = [] 103: for (const assistantMsg of group) { 104: const toolUseId = ( 105: assistantMsg.message.content[0] as { id: string } 106: ).id 107: const resultMsg = resultsByToolUseId.get(toolUseId) 108: if (resultMsg) { 109: results.push(resultMsg) 110: } 111: } 112: const groupedMessage: GroupedToolUseMessage = { 113: type: 'grouped_tool_use', 114: toolName: info.toolName, 115: messages: group, 116: results, 117: displayMessage: firstMsg, 118: uuid: `grouped-${firstMsg.uuid}`, 119: timestamp: firstMsg.timestamp, 120: messageId: info.messageId, 121: } 122: result.push(groupedMessage) 123: } 124: continue 125: } 126: } 127: if (msg.type === 'user') { 128: const toolResults = msg.message.content.filter( 129: (c): c is ToolResultBlockParam => c.type === 'tool_result', 130: ) 131: if (toolResults.length > 0) { 132: const allGrouped = toolResults.every(tr => 133: groupedToolUseIds.has(tr.tool_use_id), 134: ) 135: if (allGrouped) { 136: continue 137: } 138: } 139: } 140: result.push(msg) 141: } 142: return { messages: result } 143: }

File: src/utils/handlePromptSubmit.ts

typescript 1: import type { UUID } from 'crypto' 2: import { logEvent } from 'src/services/analytics/index.js' 3: import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js' 4: import { type Command, getCommandName, isCommandEnabled } from '../commands.js' 5: import { selectableUserMessagesFilter } from '../components/MessageSelector.js' 6: import type { SpinnerMode } from '../components/Spinner/types.js' 7: import type { QuerySource } from '../constants/querySource.js' 8: import { expandPastedTextRefs, parseReferences } from '../history.js' 9: import type { CanUseToolFn } from '../hooks/useCanUseTool.js' 10: import type { IDESelection } from '../hooks/useIdeSelection.js' 11: import type { AppState } from '../state/AppState.js' 12: import type { SetToolJSXFn } from '../Tool.js' 13: import type { LocalJSXCommandOnDone } from '../types/command.js' 14: import type { Message } from '../types/message.js' 15: import { 16: isValidImagePaste, 17: type PromptInputMode, 18: type QueuedCommand, 19: } from '../types/textInputTypes.js' 20: import { createAbortController } from './abortController.js' 21: import type { PastedContent } from './config.js' 22: import { logForDebugging } from './debug.js' 23: import type { EffortValue } from './effort.js' 24: import type { FileHistoryState } from './fileHistory.js' 25: import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js' 26: import { gracefulShutdownSync } from './gracefulShutdown.js' 27: import { enqueue } from './messageQueueManager.js' 28: import { resolveSkillModelOverride } from './model/model.js' 29: import type { ProcessUserInputContext } from './processUserInput/processUserInput.js' 30: import { processUserInput } from './processUserInput/processUserInput.js' 31: import type { QueryGuard } from './QueryGuard.js' 32: import { queryCheckpoint, startQueryProfile } from './queryProfiler.js' 33: import { runWithWorkload } from './workloadContext.js' 34: function exit(): void { 35: gracefulShutdownSync(0) 36: } 37: type BaseExecutionParams = { 38: queuedCommands?: QueuedCommand[] 39: messages: Message[] 40: mainLoopModel: string 41: ideSelection: IDESelection | undefined 42: querySource: QuerySource 43: commands: Command[] 44: queryGuard: QueryGuard 45: isExternalLoading?: boolean 46: setToolJSX: SetToolJSXFn 47: getToolUseContext: ( 48: messages: Message[], 49: newMessages: Message[], 50: abortController: AbortController, 51: mainLoopModel: string, 52: ) => ProcessUserInputContext 53: setUserInputOnProcessing: (prompt?: string) => void 54: setAbortController: (abortController: AbortController | null) => void 55: onQuery: ( 56: newMessages: Message[], 57: abortController: AbortController, 58: shouldQuery: boolean, 59: additionalAllowedTools: string[], 60: mainLoopModel: string, 61: onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>, 62: input?: string, 63: effort?: EffortValue, 64: ) => Promise<void> 65: setAppState: (updater: (prev: AppState) => AppState) => void 66: onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean> 67: canUseTool?: CanUseToolFn 68: } 69: type ExecuteUserInputParams = BaseExecutionParams & { 70: resetHistory: () => void 71: onInputChange: (value: string) => void 72: } 73: export type PromptInputHelpers = { 74: setCursorOffset: (offset: number) => void 75: clearBuffer: () => void 76: resetHistory: () => void 77: } 78: export type HandlePromptSubmitParams = BaseExecutionParams & { 79: input?: string 80: mode?: PromptInputMode 81: pastedContents?: Record<number, PastedContent> 82: helpers: PromptInputHelpers 83: onInputChange: (value: string) => void 84: setPastedContents: React.Dispatch< 85: React.SetStateAction<Record<number, PastedContent>> 86: > 87: abortController?: AbortController | null 88: addNotification?: (notification: { 89: key: string 90: text: string 91: priority: 'low' | 'medium' | 'high' | 'immediate' 92: }) => void 93: setMessages?: (updater: (prev: Message[]) => Message[]) => void 94: streamMode?: SpinnerMode 95: hasInterruptibleToolInProgress?: boolean 96: uuid?: UUID 97: skipSlashCommands?: boolean 98: } 99: export async function handlePromptSubmit( 100: params: HandlePromptSubmitParams, 101: ): Promise<void> { 102: const { 103: helpers, 104: queryGuard, 105: isExternalLoading = false, 106: commands, 107: onInputChange, 108: setPastedContents, 109: setToolJSX, 110: getToolUseContext, 111: messages, 112: mainLoopModel, 113: ideSelection, 114: setUserInputOnProcessing, 115: setAbortController, 116: onQuery, 117: setAppState, 118: onBeforeQuery, 119: canUseTool, 120: queuedCommands, 121: uuid, 122: skipSlashCommands, 123: } = params 124: const { setCursorOffset, clearBuffer, resetHistory } = helpers 125: if (queuedCommands?.length) { 126: startQueryProfile() 127: await executeUserInput({ 128: queuedCommands, 129: messages, 130: mainLoopModel, 131: ideSelection, 132: querySource: params.querySource, 133: commands, 134: queryGuard, 135: setToolJSX, 136: getToolUseContext, 137: setUserInputOnProcessing, 138: setAbortController, 139: onQuery, 140: setAppState, 141: onBeforeQuery, 142: resetHistory, 143: canUseTool, 144: onInputChange, 145: }) 146: return 147: } 148: const input = params.input ?? '' 149: const mode = params.mode ?? 'prompt' 150: const rawPastedContents = params.pastedContents ?? {} 151: const referencedIds = new Set(parseReferences(input).map(r => r.id)) 152: const pastedContents = Object.fromEntries( 153: Object.entries(rawPastedContents).filter( 154: ([, c]) => c.type !== 'image' || referencedIds.has(c.id), 155: ), 156: ) 157: const hasImages = Object.values(pastedContents).some(isValidImagePaste) 158: if (input.trim() === '') { 159: return 160: } 161: // Handle exit commands by triggering the exit command instead of direct process.exit 162: // Skip for remote bridge messages — "exit" typed on iOS shouldn't kill the local session 163: if ( 164: !skipSlashCommands && 165: ['exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(input.trim()) 166: ) { 167: const exitCommand = commands.find(cmd => cmd.name === 'exit') 168: if (exitCommand) { 169: void handlePromptSubmit({ 170: ...params, 171: input: '/exit', 172: }) 173: } else { 174: exit() 175: } 176: return 177: } 178: const finalInput = expandPastedTextRefs(input, pastedContents) 179: const pastedTextRefs = parseReferences(input).filter( 180: r => pastedContents[r.id]?.type === 'text', 181: ) 182: const pastedTextCount = pastedTextRefs.length 183: const pastedTextBytes = pastedTextRefs.reduce( 184: (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 185: 0, 186: ) 187: logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes }) 188: if (!skipSlashCommands && finalInput.trim().startsWith('/')) { 189: const trimmedInput = finalInput.trim() 190: const spaceIndex = trimmedInput.indexOf(' ') 191: const commandName = 192: spaceIndex === -1 193: ? trimmedInput.slice(1) 194: : trimmedInput.slice(1, spaceIndex) 195: const commandArgs = 196: spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim() 197: const immediateCommand = commands.find( 198: cmd => 199: cmd.immediate && 200: isCommandEnabled(cmd) && 201: (cmd.name === commandName || 202: cmd.aliases?.includes(commandName) || 203: getCommandName(cmd) === commandName), 204: ) 205: if ( 206: immediateCommand && 207: immediateCommand.type === 'local-jsx' && 208: (queryGuard.isActive || isExternalLoading) 209: ) { 210: logEvent('tengu_immediate_command_executed', { 211: commandName: 212: immediateCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 213: }) 214: onInputChange('') 215: setCursorOffset(0) 216: setPastedContents({}) 217: clearBuffer() 218: const context = getToolUseContext( 219: messages, 220: [], 221: createAbortController(), 222: mainLoopModel, 223: ) 224: let doneWasCalled = false 225: const onDone: LocalJSXCommandOnDone = (result, options) => { 226: doneWasCalled = true 227: // Use clearLocalJSX to explicitly clear the local JSX command 228: setToolJSX({ 229: jsx: null, 230: shouldHidePromptInput: false, 231: clearLocalJSX: true, 232: }) 233: if (result && options?.display !== 'skip' && params.addNotification) { 234: params.addNotification({ 235: key: `immediate-${immediateCommand.name}`, 236: text: result, 237: priority: 'immediate', 238: }) 239: } 240: if (options?.nextInput) { 241: if (options.submitNextInput) { 242: enqueue({ value: options.nextInput, mode: 'prompt' }) 243: } else { 244: onInputChange(options.nextInput) 245: } 246: } 247: } 248: const impl = await immediateCommand.load() 249: const jsx = await impl.call(onDone, context, commandArgs) 250: if (jsx && !doneWasCalled) { 251: setToolJSX({ 252: jsx, 253: shouldHidePromptInput: false, 254: isLocalJSXCommand: true, 255: isImmediate: true, 256: }) 257: } 258: return 259: } 260: } 261: if (queryGuard.isActive || isExternalLoading) { 262: if (mode !== 'prompt' && mode !== 'bash') { 263: return 264: } 265: if (params.hasInterruptibleToolInProgress) { 266: logForDebugging( 267: `[interrupt] Aborting current turn: streamMode=${params.streamMode}`, 268: ) 269: logEvent('tengu_cancel', { 270: source: 271: 'interrupt_on_submit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 272: streamMode: 273: params.streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 274: }) 275: params.abortController?.abort('interrupt') 276: } 277: enqueue({ 278: value: finalInput.trim(), 279: preExpansionValue: input.trim(), 280: mode, 281: pastedContents: hasImages ? pastedContents : undefined, 282: skipSlashCommands, 283: uuid, 284: }) 285: onInputChange('') 286: setCursorOffset(0) 287: setPastedContents({}) 288: resetHistory() 289: clearBuffer() 290: return 291: } 292: // Start query profiling for this query 293: startQueryProfile() 294: // Construct a QueuedCommand from the direct user input so both paths 295: // go through the same executeUserInput loop. This ensures images get 296: // resized via processUserInput regardless of how the command arrives. 297: const cmd: QueuedCommand = { 298: value: finalInput, 299: preExpansionValue: input, 300: mode, 301: pastedContents: hasImages ? pastedContents : undefined, 302: skipSlashCommands, 303: uuid, 304: } 305: await executeUserInput({ 306: queuedCommands: [cmd], 307: messages, 308: mainLoopModel, 309: ideSelection, 310: querySource: params.querySource, 311: commands, 312: queryGuard, 313: setToolJSX, 314: getToolUseContext, 315: setUserInputOnProcessing, 316: setAbortController, 317: onQuery, 318: setAppState, 319: onBeforeQuery, 320: resetHistory, 321: canUseTool, 322: onInputChange, 323: }) 324: } 325: /** 326: * Core logic for executing user input without UI side effects. 327: * 328: * All commands arrive as `queuedCommands`. First command gets full treatment 329: * (attachments, ideSelection, pastedContents with image resizing). Commands 2-N 330: * get `skipAttachments` to avoid duplicating turn-level context. 331: */ 332: async function executeUserInput(params: ExecuteUserInputParams): Promise<void> { 333: const { 334: messages, 335: mainLoopModel, 336: ideSelection, 337: querySource, 338: queryGuard, 339: setToolJSX, 340: getToolUseContext, 341: setUserInputOnProcessing, 342: setAbortController, 343: onQuery, 344: setAppState, 345: onBeforeQuery, 346: resetHistory, 347: canUseTool, 348: queuedCommands, 349: } = params 350: // Note: paste references are already processed before calling this function 351: // (either in handlePromptSubmit before queuing, or before initial execution). 352: // Always create a fresh abort controller — queryGuard guarantees no concurrent 353: // executeUserInput call, so there's no prior controller to inherit. 354: const abortController = createAbortController() 355: setAbortController(abortController) 356: function makeContext(): ProcessUserInputContext { 357: return getToolUseContext(messages, [], abortController, mainLoopModel) 358: } 359: try { 360: queryGuard.reserve() 361: queryCheckpoint('query_process_user_input_start') 362: const newMessages: Message[] = [] 363: let shouldQuery = false 364: let allowedTools: string[] | undefined 365: let model: string | undefined 366: let effort: EffortValue | undefined 367: let nextInput: string | undefined 368: let submitNextInput: boolean | undefined 369: const commands = queuedCommands ?? [] 370: const firstWorkload = commands[0]?.workload 371: const turnWorkload = 372: firstWorkload !== undefined && 373: commands.every(c => c.workload === firstWorkload) 374: ? firstWorkload 375: : undefined 376: await runWithWorkload(turnWorkload, async () => { 377: for (let i = 0; i < commands.length; i++) { 378: const cmd = commands[i]! 379: const isFirst = i === 0 380: const result = await processUserInput({ 381: input: cmd.value, 382: preExpansionInput: cmd.preExpansionValue, 383: mode: cmd.mode, 384: setToolJSX, 385: context: makeContext(), 386: pastedContents: isFirst ? cmd.pastedContents : undefined, 387: messages, 388: setUserInputOnProcessing: isFirst 389: ? setUserInputOnProcessing 390: : undefined, 391: isAlreadyProcessing: !isFirst, 392: querySource, 393: canUseTool, 394: uuid: cmd.uuid, 395: ideSelection: isFirst ? ideSelection : undefined, 396: skipSlashCommands: cmd.skipSlashCommands, 397: bridgeOrigin: cmd.bridgeOrigin, 398: isMeta: cmd.isMeta, 399: skipAttachments: !isFirst, 400: }) 401: const origin = 402: cmd.origin ?? 403: (cmd.mode === 'task-notification' 404: ? ({ kind: 'task-notification' } as const) 405: : undefined) 406: if (origin) { 407: for (const m of result.messages) { 408: if (m.type === 'user') m.origin = origin 409: } 410: } 411: newMessages.push(...result.messages) 412: if (isFirst) { 413: shouldQuery = result.shouldQuery 414: allowedTools = result.allowedTools 415: model = result.model 416: effort = result.effort 417: nextInput = result.nextInput 418: submitNextInput = result.submitNextInput 419: } 420: } 421: queryCheckpoint('query_process_user_input_end') 422: if (fileHistoryEnabled()) { 423: queryCheckpoint('query_file_history_snapshot_start') 424: newMessages.filter(selectableUserMessagesFilter).forEach(message => { 425: void fileHistoryMakeSnapshot( 426: (updater: (prev: FileHistoryState) => FileHistoryState) => { 427: setAppState(prev => ({ 428: ...prev, 429: fileHistory: updater(prev.fileHistory), 430: })) 431: }, 432: message.uuid, 433: ) 434: }) 435: queryCheckpoint('query_file_history_snapshot_end') 436: } 437: if (newMessages.length) { 438: resetHistory() 439: setToolJSX({ 440: jsx: null, 441: shouldHidePromptInput: false, 442: clearLocalJSX: true, 443: }) 444: const primaryCmd = commands[0] 445: const primaryMode = primaryCmd?.mode ?? 'prompt' 446: const primaryInput = 447: primaryCmd && typeof primaryCmd.value === 'string' 448: ? primaryCmd.value 449: : undefined 450: const shouldCallBeforeQuery = primaryMode === 'prompt' 451: await onQuery( 452: newMessages, 453: abortController, 454: shouldQuery, 455: allowedTools ?? [], 456: model 457: ? resolveSkillModelOverride(model, mainLoopModel) 458: : mainLoopModel, 459: shouldCallBeforeQuery ? onBeforeQuery : undefined, 460: primaryInput, 461: effort, 462: ) 463: } else { 464: queryGuard.cancelReservation() 465: setToolJSX({ 466: jsx: null, 467: shouldHidePromptInput: false, 468: clearLocalJSX: true, 469: }) 470: resetHistory() 471: setAbortController(null) 472: } 473: if (nextInput) { 474: if (submitNextInput) { 475: enqueue({ value: nextInput, mode: 'prompt' }) 476: } else { 477: params.onInputChange(nextInput) 478: } 479: } 480: }) 481: } finally { 482: queryGuard.cancelReservation() 483: setUserInputOnProcessing(undefined) 484: } 485: }

File: src/utils/hash.ts

typescript 1: export function djb2Hash(str: string): number { 2: let hash = 0 3: for (let i = 0; i < str.length; i++) { 4: hash = ((hash << 5) - hash + str.charCodeAt(i)) | 0 5: } 6: return hash 7: } 8: export function hashContent(content: string): string { 9: if (typeof Bun !== 'undefined') { 10: return Bun.hash(content).toString() 11: } 12: const crypto = require('crypto') as typeof import('crypto') 13: return crypto.createHash('sha256').update(content).digest('hex') 14: } 15: export function hashPair(a: string, b: string): string { 16: if (typeof Bun !== 'undefined') { 17: return Bun.hash(b, Bun.hash(a)).toString() 18: } 19: const crypto = require('crypto') as typeof import('crypto') 20: return crypto 21: .createHash('sha256') 22: .update(a) 23: .update('\0') 24: .update(b) 25: .digest('hex') 26: }

File: src/utils/headlessProfiler.ts

typescript 1: import { getIsNonInteractiveSession } from '../bootstrap/state.js' 2: import { 3: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 4: logEvent, 5: } from '../services/analytics/index.js' 6: import { logForDebugging } from './debug.js' 7: import { isEnvTruthy } from './envUtils.js' 8: import { getPerformance } from './profilerBase.js' 9: import { jsonStringify } from './slowOperations.js' 10: const DETAILED_PROFILING = isEnvTruthy(process.env.CLAUDE_CODE_PROFILE_STARTUP) 11: const STATSIG_SAMPLE_RATE = 0.05 12: const STATSIG_LOGGING_SAMPLED = 13: process.env.USER_TYPE === 'ant' || Math.random() < STATSIG_SAMPLE_RATE 14: const SHOULD_PROFILE = DETAILED_PROFILING || STATSIG_LOGGING_SAMPLED 15: const MARK_PREFIX = 'headless_' 16: let currentTurnNumber = -1 17: function clearHeadlessMarks(): void { 18: const perf = getPerformance() 19: const allMarks = perf.getEntriesByType('mark') 20: for (const mark of allMarks) { 21: if (mark.name.startsWith(MARK_PREFIX)) { 22: perf.clearMarks(mark.name) 23: } 24: } 25: } 26: export function headlessProfilerStartTurn(): void { 27: if (!getIsNonInteractiveSession()) return 28: if (!SHOULD_PROFILE) return 29: currentTurnNumber++ 30: clearHeadlessMarks() 31: const perf = getPerformance() 32: perf.mark(`${MARK_PREFIX}turn_start`) 33: if (DETAILED_PROFILING) { 34: logForDebugging(`[headlessProfiler] Started turn ${currentTurnNumber}`) 35: } 36: } 37: export function headlessProfilerCheckpoint(name: string): void { 38: if (!getIsNonInteractiveSession()) return 39: if (!SHOULD_PROFILE) return 40: const perf = getPerformance() 41: perf.mark(`${MARK_PREFIX}${name}`) 42: if (DETAILED_PROFILING) { 43: logForDebugging( 44: `[headlessProfiler] Checkpoint: ${name} at ${perf.now().toFixed(1)}ms`, 45: ) 46: } 47: } 48: export function logHeadlessProfilerTurn(): void { 49: if (!getIsNonInteractiveSession()) return 50: if (!SHOULD_PROFILE) return 51: const perf = getPerformance() 52: const allMarks = perf.getEntriesByType('mark') 53: const marks = allMarks.filter(mark => mark.name.startsWith(MARK_PREFIX)) 54: if (marks.length === 0) return 55: const checkpointTimes = new Map<string, number>() 56: for (const mark of marks) { 57: const name = mark.name.slice(MARK_PREFIX.length) 58: checkpointTimes.set(name, mark.startTime) 59: } 60: const turnStart = checkpointTimes.get('turn_start') 61: if (turnStart === undefined) return 62: const metadata: Record<string, number | string | undefined> = { 63: turn_number: currentTurnNumber, 64: } 65: const systemMessageTime = checkpointTimes.get('system_message_yielded') 66: if (systemMessageTime !== undefined && currentTurnNumber === 0) { 67: metadata.time_to_system_message_ms = Math.round(systemMessageTime) 68: } 69: const queryStartTime = checkpointTimes.get('query_started') 70: if (queryStartTime !== undefined) { 71: metadata.time_to_query_start_ms = Math.round(queryStartTime - turnStart) 72: } 73: const firstChunkTime = checkpointTimes.get('first_chunk') 74: if (firstChunkTime !== undefined) { 75: metadata.time_to_first_response_ms = Math.round(firstChunkTime - turnStart) 76: } 77: const apiRequestTime = checkpointTimes.get('api_request_sent') 78: if (queryStartTime !== undefined && apiRequestTime !== undefined) { 79: metadata.query_overhead_ms = Math.round(apiRequestTime - queryStartTime) 80: } 81: metadata.checkpoint_count = marks.length 82: if (process.env.CLAUDE_CODE_ENTRYPOINT) { 83: metadata.entrypoint = process.env.CLAUDE_CODE_ENTRYPOINT 84: } 85: if (STATSIG_LOGGING_SAMPLED) { 86: logEvent( 87: 'tengu_headless_latency', 88: metadata as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 89: ) 90: } 91: if (DETAILED_PROFILING) { 92: logForDebugging( 93: `[headlessProfiler] Turn ${currentTurnNumber} metrics: ${jsonStringify(metadata)}`, 94: ) 95: } 96: }

File: src/utils/heapDumpService.ts

typescript 1: import { createWriteStream, writeFileSync } from 'fs' 2: import { readdir, readFile, writeFile } from 'fs/promises' 3: import { join } from 'path' 4: import { pipeline } from 'stream/promises' 5: import { 6: getHeapSnapshot, 7: getHeapSpaceStatistics, 8: getHeapStatistics, 9: type HeapSpaceInfo, 10: } from 'v8' 11: import { getSessionId } from '../bootstrap/state.js' 12: import { logEvent } from '../services/analytics/index.js' 13: import { logForDebugging } from './debug.js' 14: import { toError } from './errors.js' 15: import { getDesktopPath } from './file.js' 16: import { getFsImplementation } from './fsOperations.js' 17: import { logError } from './log.js' 18: import { jsonStringify } from './slowOperations.js' 19: export type HeapDumpResult = { 20: success: boolean 21: heapPath?: string 22: diagPath?: string 23: error?: string 24: } 25: export type MemoryDiagnostics = { 26: timestamp: string 27: sessionId: string 28: trigger: 'manual' | 'auto-1.5GB' 29: dumpNumber: number 30: uptimeSeconds: number 31: memoryUsage: { 32: heapUsed: number 33: heapTotal: number 34: external: number 35: arrayBuffers: number 36: rss: number 37: } 38: memoryGrowthRate: { 39: bytesPerSecond: number 40: mbPerHour: number 41: } 42: v8HeapStats: { 43: heapSizeLimit: number 44: mallocedMemory: number 45: peakMallocedMemory: number 46: detachedContexts: number 47: nativeContexts: number 48: } 49: v8HeapSpaces?: Array<{ 50: name: string 51: size: number 52: used: number 53: available: number 54: }> 55: resourceUsage: { 56: maxRSS: number 57: userCPUTime: number 58: systemCPUTime: number 59: } 60: activeHandles: number 61: activeRequests: number 62: openFileDescriptors?: number 63: analysis: { 64: potentialLeaks: string[] 65: recommendation: string 66: } 67: smapsRollup?: string 68: platform: string 69: nodeVersion: string 70: ccVersion: string 71: } 72: export async function captureMemoryDiagnostics( 73: trigger: 'manual' | 'auto-1.5GB', 74: dumpNumber = 0, 75: ): Promise<MemoryDiagnostics> { 76: const usage = process.memoryUsage() 77: const heapStats = getHeapStatistics() 78: const resourceUsage = process.resourceUsage() 79: const uptimeSeconds = process.uptime() 80: let heapSpaceStats: HeapSpaceInfo[] | undefined 81: try { 82: heapSpaceStats = getHeapSpaceStatistics() 83: } catch { 84: } 85: const activeHandles = ( 86: process as unknown as { _getActiveHandles: () => unknown[] } 87: )._getActiveHandles().length 88: const activeRequests = ( 89: process as unknown as { _getActiveRequests: () => unknown[] } 90: )._getActiveRequests().length 91: let openFileDescriptors: number | undefined 92: try { 93: openFileDescriptors = (await readdir('/proc/self/fd')).length 94: } catch { 95: } 96: let smapsRollup: string | undefined 97: try { 98: smapsRollup = await readFile('/proc/self/smaps_rollup', 'utf8') 99: } catch { 100: } 101: const nativeMemory = usage.rss - usage.heapUsed 102: const bytesPerSecond = uptimeSeconds > 0 ? usage.rss / uptimeSeconds : 0 103: const mbPerHour = (bytesPerSecond * 3600) / (1024 * 1024) 104: const potentialLeaks: string[] = [] 105: if (heapStats.number_of_detached_contexts > 0) { 106: potentialLeaks.push( 107: `${heapStats.number_of_detached_contexts} detached context(s) - possible iframe/context leak`, 108: ) 109: } 110: if (activeHandles > 100) { 111: potentialLeaks.push( 112: `${activeHandles} active handles - possible timer/socket leak`, 113: ) 114: } 115: if (nativeMemory > usage.heapUsed) { 116: potentialLeaks.push( 117: 'Native memory > heap - leak may be in native addons (node-pty, sharp, etc.)', 118: ) 119: } 120: if (mbPerHour > 100) { 121: potentialLeaks.push( 122: `High memory growth rate: ${mbPerHour.toFixed(1)} MB/hour`, 123: ) 124: } 125: if (openFileDescriptors && openFileDescriptors > 500) { 126: potentialLeaks.push( 127: `${openFileDescriptors} open file descriptors - possible file/socket leak`, 128: ) 129: } 130: return { 131: timestamp: new Date().toISOString(), 132: sessionId: getSessionId(), 133: trigger, 134: dumpNumber, 135: uptimeSeconds, 136: memoryUsage: { 137: heapUsed: usage.heapUsed, 138: heapTotal: usage.heapTotal, 139: external: usage.external, 140: arrayBuffers: usage.arrayBuffers, 141: rss: usage.rss, 142: }, 143: memoryGrowthRate: { 144: bytesPerSecond, 145: mbPerHour, 146: }, 147: v8HeapStats: { 148: heapSizeLimit: heapStats.heap_size_limit, 149: mallocedMemory: heapStats.malloced_memory, 150: peakMallocedMemory: heapStats.peak_malloced_memory, 151: detachedContexts: heapStats.number_of_detached_contexts, 152: nativeContexts: heapStats.number_of_native_contexts, 153: }, 154: v8HeapSpaces: heapSpaceStats?.map(space => ({ 155: name: space.space_name, 156: size: space.space_size, 157: used: space.space_used_size, 158: available: space.space_available_size, 159: })), 160: resourceUsage: { 161: maxRSS: resourceUsage.maxRSS * 1024, 162: userCPUTime: resourceUsage.userCPUTime, 163: systemCPUTime: resourceUsage.systemCPUTime, 164: }, 165: activeHandles, 166: activeRequests, 167: openFileDescriptors, 168: analysis: { 169: potentialLeaks, 170: recommendation: 171: potentialLeaks.length > 0 172: ? `WARNING: ${potentialLeaks.length} potential leak indicator(s) found. See potentialLeaks array.` 173: : 'No obvious leak indicators. Check heap snapshot for retained objects.', 174: }, 175: smapsRollup, 176: platform: process.platform, 177: nodeVersion: process.version, 178: ccVersion: MACRO.VERSION, 179: } 180: } 181: export async function performHeapDump( 182: trigger: 'manual' | 'auto-1.5GB' = 'manual', 183: dumpNumber = 0, 184: ): Promise<HeapDumpResult> { 185: try { 186: const sessionId = getSessionId() 187: const diagnostics = await captureMemoryDiagnostics(trigger, dumpNumber) 188: const toGB = (bytes: number): string => 189: (bytes / 1024 / 1024 / 1024).toFixed(3) 190: logForDebugging(`[HeapDump] Memory state: 191: heapUsed: ${toGB(diagnostics.memoryUsage.heapUsed)} GB (in snapshot) 192: external: ${toGB(diagnostics.memoryUsage.external)} GB (NOT in snapshot) 193: rss: ${toGB(diagnostics.memoryUsage.rss)} GB (total process) 194: ${diagnostics.analysis.recommendation}`) 195: const dumpDir = getDesktopPath() 196: await getFsImplementation().mkdir(dumpDir) 197: const suffix = dumpNumber > 0 ? `-dump${dumpNumber}` : '' 198: const heapFilename = `${sessionId}${suffix}.heapsnapshot` 199: const diagFilename = `${sessionId}${suffix}-diagnostics.json` 200: const heapPath = join(dumpDir, heapFilename) 201: const diagPath = join(dumpDir, diagFilename) 202: // Write diagnostics first (cheap, unlikely to fail) 203: await writeFile(diagPath, jsonStringify(diagnostics, null, 2), { 204: mode: 0o600, 205: }) 206: logForDebugging(`[HeapDump] Diagnostics written to ${diagPath}`) 207: // Write heap snapshot (this can crash for very large heaps) 208: await writeHeapSnapshot(heapPath) 209: logForDebugging(`[HeapDump] Heap dump written to ${heapPath}`) 210: logEvent('tengu_heap_dump', { 211: triggerManual: trigger === 'manual', 212: triggerAuto15GB: trigger === 'auto-1.5GB', 213: dumpNumber, 214: success: true, 215: }) 216: return { success: true, heapPath, diagPath } 217: } catch (err) { 218: const error = toError(err) 219: logError(error) 220: logEvent('tengu_heap_dump', { 221: triggerManual: trigger === 'manual', 222: triggerAuto15GB: trigger === 'auto-1.5GB', 223: dumpNumber, 224: success: false, 225: }) 226: return { success: false, error: error.message } 227: } 228: } 229: async function writeHeapSnapshot(filepath: string): Promise<void> { 230: if (typeof Bun !== 'undefined') { 231: writeFileSync(filepath, Bun.generateHeapSnapshot('v8', 'arraybuffer'), { 232: mode: 0o600, 233: }) 234: Bun.gc(true) 235: return 236: } 237: const writeStream = createWriteStream(filepath, { mode: 0o600 }) 238: const heapSnapshotStream = getHeapSnapshot() 239: await pipeline(heapSnapshotStream, writeStream) 240: }

File: src/utils/heatmap.ts

typescript 1: import chalk from 'chalk' 2: import type { DailyActivity } from './stats.js' 3: import { toDateString } from './statsCache.js' 4: export type HeatmapOptions = { 5: terminalWidth?: number 6: showMonthLabels?: boolean 7: } 8: type Percentiles = { 9: p25: number 10: p50: number 11: p75: number 12: } 13: function calculatePercentiles( 14: dailyActivity: DailyActivity[], 15: ): Percentiles | null { 16: const counts = dailyActivity 17: .map(a => a.messageCount) 18: .filter(c => c > 0) 19: .sort((a, b) => a - b) 20: if (counts.length === 0) return null 21: return { 22: p25: counts[Math.floor(counts.length * 0.25)]!, 23: p50: counts[Math.floor(counts.length * 0.5)]!, 24: p75: counts[Math.floor(counts.length * 0.75)]!, 25: } 26: } 27: export function generateHeatmap( 28: dailyActivity: DailyActivity[], 29: options: HeatmapOptions = {}, 30: ): string { 31: const { terminalWidth = 80, showMonthLabels = true } = options 32: const dayLabelWidth = 4 33: const availableWidth = terminalWidth - dayLabelWidth 34: const width = Math.min(52, Math.max(10, availableWidth)) 35: const activityMap = new Map<string, DailyActivity>() 36: for (const activity of dailyActivity) { 37: activityMap.set(activity.date, activity) 38: } 39: const percentiles = calculatePercentiles(dailyActivity) 40: const today = new Date() 41: today.setHours(0, 0, 0, 0) 42: const currentWeekStart = new Date(today) 43: currentWeekStart.setDate(today.getDate() - today.getDay()) 44: const startDate = new Date(currentWeekStart) 45: startDate.setDate(startDate.getDate() - (width - 1) * 7) 46: const grid: string[][] = Array.from({ length: 7 }, () => 47: Array(width).fill(''), 48: ) 49: const monthStarts: { month: number; week: number }[] = [] 50: let lastMonth = -1 51: const currentDate = new Date(startDate) 52: for (let week = 0; week < width; week++) { 53: for (let day = 0; day < 7; day++) { 54: // Don't show future dates 55: if (currentDate > today) { 56: grid[day]![week] = ' ' 57: currentDate.setDate(currentDate.getDate() + 1) 58: continue 59: } 60: const dateStr = toDateString(currentDate) 61: const activity = activityMap.get(dateStr) 62: if (day === 0) { 63: const month = currentDate.getMonth() 64: if (month !== lastMonth) { 65: monthStarts.push({ month, week }) 66: lastMonth = month 67: } 68: } 69: const intensity = getIntensity(activity?.messageCount || 0, percentiles) 70: grid[day]![week] = getHeatmapChar(intensity) 71: currentDate.setDate(currentDate.getDate() + 1) 72: } 73: } 74: const lines: string[] = [] 75: if (showMonthLabels) { 76: const monthNames = [ 77: 'Jan', 78: 'Feb', 79: 'Mar', 80: 'Apr', 81: 'May', 82: 'Jun', 83: 'Jul', 84: 'Aug', 85: 'Sep', 86: 'Oct', 87: 'Nov', 88: 'Dec', 89: ] 90: const uniqueMonths = monthStarts.map(m => m.month) 91: const labelWidth = Math.floor(width / Math.max(uniqueMonths.length, 1)) 92: const monthLabels = uniqueMonths 93: .map(month => monthNames[month]!.padEnd(labelWidth)) 94: .join('') 95: // 4 spaces for day label column prefix 96: lines.push(' ' + monthLabels) 97: } 98: // Day labels 99: const dayLabels = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] 100: for (let day = 0; day < 7; day++) { 101: const label = [1, 3, 5].includes(day) ? dayLabels[day]!.padEnd(3) : ' ' 102: const row = label + ' ' + grid[day]!.join('') 103: lines.push(row) 104: } 105: // Legend 106: lines.push('') 107: lines.push( 108: ' Less ' + 109: [ 110: claudeOrange('░'), 111: claudeOrange('▒'), 112: claudeOrange('▓'), 113: claudeOrange('█'), 114: ].join(' ') + 115: ' More', 116: ) 117: return lines.join('\n') 118: } 119: function getIntensity( 120: messageCount: number, 121: percentiles: Percentiles | null, 122: ): number { 123: if (messageCount === 0 || !percentiles) return 0 124: if (messageCount >= percentiles.p75) return 4 125: if (messageCount >= percentiles.p50) return 3 126: if (messageCount >= percentiles.p25) return 2 127: return 1 128: } 129: const claudeOrange = chalk.hex('#da7756') 130: function getHeatmapChar(intensity: number): string { 131: switch (intensity) { 132: case 0: 133: return chalk.gray('·') 134: case 1: 135: return claudeOrange('░') 136: case 2: 137: return claudeOrange('▒') 138: case 3: 139: return claudeOrange('▓') 140: case 4: 141: return claudeOrange('█') 142: default: 143: return chalk.gray('·') 144: } 145: }

File: src/utils/highlightMatch.tsx

typescript 1: import * as React from 'react'; 2: import { Text } from '../ink.js'; 3: export function highlightMatch(text: string, query: string): React.ReactNode { 4: if (!query) return text; 5: const queryLower = query.toLowerCase(); 6: const textLower = text.toLowerCase(); 7: const parts: React.ReactNode[] = []; 8: let offset = 0; 9: let idx = textLower.indexOf(queryLower, offset); 10: if (idx === -1) return text; 11: while (idx !== -1) { 12: if (idx > offset) parts.push(text.slice(offset, idx)); 13: parts.push(<Text key={idx} inverse> 14: {text.slice(idx, idx + query.length)} 15: </Text>); 16: offset = idx + query.length; 17: idx = textLower.indexOf(queryLower, offset); 18: } 19: if (offset < text.length) parts.push(text.slice(offset)); 20: return <>{parts}</>; 21: }

File: src/utils/hooks.ts

typescript 1: import { basename } from 'path' 2: import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' 3: import { pathExists } from './file.js' 4: import { wrapSpawn } from './ShellCommand.js' 5: import { TaskOutput } from './task/TaskOutput.js' 6: import { getCwd } from './cwd.js' 7: import { randomUUID } from 'crypto' 8: import { formatShellPrefixCommand } from './bash/shellPrefix.js' 9: import { 10: getHookEnvFilePath, 11: invalidateSessionEnvCache, 12: } from './sessionEnvironment.js' 13: import { subprocessEnv } from './subprocessEnv.js' 14: import { getPlatform } from './platform.js' 15: import { findGitBashPath, windowsPathToPosixPath } from './windowsPaths.js' 16: import { getCachedPowerShellPath } from './shell/powershellDetection.js' 17: import { DEFAULT_HOOK_SHELL } from './shell/shellProvider.js' 18: import { buildPowerShellArgs } from './shell/powershellProvider.js' 19: import { 20: loadPluginOptions, 21: substituteUserConfigVariables, 22: } from './plugins/pluginOptionsStorage.js' 23: import { getPluginDataDir } from './plugins/pluginDirectories.js' 24: import { 25: getSessionId, 26: getProjectRoot, 27: getIsNonInteractiveSession, 28: getRegisteredHooks, 29: getStatsStore, 30: addToTurnHookDuration, 31: getOriginalCwd, 32: getMainThreadAgentType, 33: } from '../bootstrap/state.js' 34: import { checkHasTrustDialogAccepted } from './config.js' 35: import { 36: getHooksConfigFromSnapshot, 37: shouldAllowManagedHooksOnly, 38: shouldDisableAllHooksIncludingManaged, 39: } from './hooks/hooksConfigSnapshot.js' 40: import { 41: getTranscriptPathForSession, 42: getAgentTranscriptPath, 43: } from './sessionStorage.js' 44: import type { AgentId } from '../types/ids.js' 45: import { 46: getSettings_DEPRECATED, 47: getSettingsForSource, 48: } from './settings/settings.js' 49: import { 50: logEvent, 51: type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 52: } from 'src/services/analytics/index.js' 53: import { logOTelEvent } from './telemetry/events.js' 54: import { ALLOWED_OFFICIAL_MARKETPLACE_NAMES } from './plugins/schemas.js' 55: import { 56: startHookSpan, 57: endHookSpan, 58: isBetaTracingEnabled, 59: } from './telemetry/sessionTracing.js' 60: import { 61: hookJSONOutputSchema, 62: promptRequestSchema, 63: type HookCallback, 64: type HookCallbackMatcher, 65: type PromptRequest, 66: type PromptResponse, 67: isAsyncHookJSONOutput, 68: isSyncHookJSONOutput, 69: type PermissionRequestResult, 70: } from '../types/hooks.js' 71: import type { 72: HookEvent, 73: HookInput, 74: HookJSONOutput, 75: NotificationHookInput, 76: PostToolUseHookInput, 77: PostToolUseFailureHookInput, 78: PermissionDeniedHookInput, 79: PreCompactHookInput, 80: PostCompactHookInput, 81: PreToolUseHookInput, 82: SessionStartHookInput, 83: SessionEndHookInput, 84: SetupHookInput, 85: StopHookInput, 86: StopFailureHookInput, 87: SubagentStartHookInput, 88: SubagentStopHookInput, 89: TeammateIdleHookInput, 90: TaskCreatedHookInput, 91: TaskCompletedHookInput, 92: ConfigChangeHookInput, 93: CwdChangedHookInput, 94: FileChangedHookInput, 95: InstructionsLoadedHookInput, 96: UserPromptSubmitHookInput, 97: PermissionRequestHookInput, 98: ElicitationHookInput, 99: ElicitationResultHookInput, 100: PermissionUpdate, 101: ExitReason, 102: SyncHookJSONOutput, 103: AsyncHookJSONOutput, 104: } from 'src/entrypoints/agentSdkTypes.js' 105: import type { StatusLineCommandInput } from '../types/statusLine.js' 106: import type { ElicitResult } from '@modelcontextprotocol/sdk/types.js' 107: import type { FileSuggestionCommandInput } from '../types/fileSuggestion.js' 108: import type { HookResultMessage } from 'src/types/message.js' 109: import chalk from 'chalk' 110: import type { 111: HookMatcher, 112: HookCommand, 113: PluginHookMatcher, 114: SkillHookMatcher, 115: } from './settings/types.js' 116: import { getHookDisplayText } from './hooks/hooksSettings.js' 117: import { logForDebugging } from './debug.js' 118: import { logForDiagnosticsNoPII } from './diagLogs.js' 119: import { firstLineOf } from './stringUtils.js' 120: import { 121: normalizeLegacyToolName, 122: getLegacyToolNames, 123: permissionRuleValueFromString, 124: } from './permissions/permissionRuleParser.js' 125: import { logError } from './log.js' 126: import { createCombinedAbortSignal } from './combinedAbortSignal.js' 127: import type { PermissionResult } from './permissions/PermissionResult.js' 128: import { registerPendingAsyncHook } from './hooks/AsyncHookRegistry.js' 129: import { enqueuePendingNotification } from './messageQueueManager.js' 130: import { 131: extractTextContent, 132: getLastAssistantMessage, 133: wrapInSystemReminder, 134: } from './messages.js' 135: import { 136: emitHookStarted, 137: emitHookResponse, 138: startHookProgressInterval, 139: } from './hooks/hookEvents.js' 140: import { createAttachmentMessage } from './attachments.js' 141: import { all } from './generators.js' 142: import { findToolByName, type Tools, type ToolUseContext } from '../Tool.js' 143: import { execPromptHook } from './hooks/execPromptHook.js' 144: import type { Message, AssistantMessage } from '../types/message.js' 145: import { execAgentHook } from './hooks/execAgentHook.js' 146: import { execHttpHook } from './hooks/execHttpHook.js' 147: import type { ShellCommand } from './ShellCommand.js' 148: import { 149: getSessionHooks, 150: getSessionFunctionHooks, 151: getSessionHookCallback, 152: clearSessionHooks, 153: type SessionDerivedHookMatcher, 154: type FunctionHook, 155: } from './hooks/sessionHooks.js' 156: import type { AppState } from '../state/AppState.js' 157: import { jsonStringify, jsonParse } from './slowOperations.js' 158: import { isEnvTruthy } from './envUtils.js' 159: import { errorMessage, getErrnoCode } from './errors.js' 160: const TOOL_HOOK_EXECUTION_TIMEOUT_MS = 10 * 60 * 1000 161: const SESSION_END_HOOK_TIMEOUT_MS_DEFAULT = 1500 162: export function getSessionEndHookTimeoutMs(): number { 163: const raw = process.env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS 164: const parsed = raw ? parseInt(raw, 10) : NaN 165: return Number.isFinite(parsed) && parsed > 0 166: ? parsed 167: : SESSION_END_HOOK_TIMEOUT_MS_DEFAULT 168: } 169: function executeInBackground({ 170: processId, 171: hookId, 172: shellCommand, 173: asyncResponse, 174: hookEvent, 175: hookName, 176: command, 177: asyncRewake, 178: pluginId, 179: }: { 180: processId: string 181: hookId: string 182: shellCommand: ShellCommand 183: asyncResponse: AsyncHookJSONOutput 184: hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion' 185: hookName: string 186: command: string 187: asyncRewake?: boolean 188: pluginId?: string 189: }): boolean { 190: if (asyncRewake) { 191: void shellCommand.result.then(async result => { 192: await new Promise(resolve => setImmediate(resolve)) 193: const stdout = await shellCommand.taskOutput.getStdout() 194: const stderr = shellCommand.taskOutput.getStderr() 195: shellCommand.cleanup() 196: emitHookResponse({ 197: hookId, 198: hookName, 199: hookEvent, 200: output: stdout + stderr, 201: stdout, 202: stderr, 203: exitCode: result.code, 204: outcome: result.code === 0 ? 'success' : 'error', 205: }) 206: if (result.code === 2) { 207: enqueuePendingNotification({ 208: value: wrapInSystemReminder( 209: `Stop hook blocking error from command "${hookName}": ${stderr || stdout}`, 210: ), 211: mode: 'task-notification', 212: }) 213: } 214: }) 215: return true 216: } 217: if (!shellCommand.background(processId)) { 218: return false 219: } 220: registerPendingAsyncHook({ 221: processId, 222: hookId, 223: asyncResponse, 224: hookEvent, 225: hookName, 226: command, 227: shellCommand, 228: pluginId, 229: }) 230: return true 231: } 232: export function shouldSkipHookDueToTrust(): boolean { 233: const isInteractive = !getIsNonInteractiveSession() 234: if (!isInteractive) { 235: return false 236: } 237: const hasTrust = checkHasTrustDialogAccepted() 238: return !hasTrust 239: } 240: export function createBaseHookInput( 241: permissionMode?: string, 242: sessionId?: string, 243: agentInfo?: { agentId?: string; agentType?: string }, 244: ): { 245: session_id: string 246: transcript_path: string 247: cwd: string 248: permission_mode?: string 249: agent_id?: string 250: agent_type?: string 251: } { 252: const resolvedSessionId = sessionId ?? getSessionId() 253: const resolvedAgentType = agentInfo?.agentType ?? getMainThreadAgentType() 254: return { 255: session_id: resolvedSessionId, 256: transcript_path: getTranscriptPathForSession(resolvedSessionId), 257: cwd: getCwd(), 258: permission_mode: permissionMode, 259: agent_id: agentInfo?.agentId, 260: agent_type: resolvedAgentType, 261: } 262: } 263: export interface HookBlockingError { 264: blockingError: string 265: command: string 266: } 267: export type ElicitationResponse = ElicitResult 268: export interface HookResult { 269: message?: HookResultMessage 270: systemMessage?: string 271: blockingError?: HookBlockingError 272: outcome: 'success' | 'blocking' | 'non_blocking_error' | 'cancelled' 273: preventContinuation?: boolean 274: stopReason?: string 275: permissionBehavior?: 'ask' | 'deny' | 'allow' | 'passthrough' 276: hookPermissionDecisionReason?: string 277: additionalContext?: string 278: initialUserMessage?: string 279: updatedInput?: Record<string, unknown> 280: updatedMCPToolOutput?: unknown 281: permissionRequestResult?: PermissionRequestResult 282: elicitationResponse?: ElicitationResponse 283: watchPaths?: string[] 284: elicitationResultResponse?: ElicitationResponse 285: retry?: boolean 286: hook: HookCommand | HookCallback | FunctionHook 287: } 288: export type AggregatedHookResult = { 289: message?: HookResultMessage 290: blockingError?: HookBlockingError 291: preventContinuation?: boolean 292: stopReason?: string 293: hookPermissionDecisionReason?: string 294: hookSource?: string 295: permissionBehavior?: PermissionResult['behavior'] 296: additionalContexts?: string[] 297: initialUserMessage?: string 298: updatedInput?: Record<string, unknown> 299: updatedMCPToolOutput?: unknown 300: permissionRequestResult?: PermissionRequestResult 301: watchPaths?: string[] 302: elicitationResponse?: ElicitationResponse 303: elicitationResultResponse?: ElicitationResponse 304: retry?: boolean 305: } 306: function validateHookJson( 307: jsonString: string, 308: ): { json: HookJSONOutput } | { validationError: string } { 309: const parsed = jsonParse(jsonString) 310: const validation = hookJSONOutputSchema().safeParse(parsed) 311: if (validation.success) { 312: logForDebugging('Successfully parsed and validated hook JSON output') 313: return { json: validation.data } 314: } 315: const errors = validation.error.issues 316: .map(err => ` - ${err.path.join('.')}: ${err.message}`) 317: .join('\n') 318: return { 319: validationError: `Hook JSON output validation failed:\n${errors}\n\nThe hook's output was: ${jsonStringify(parsed, null, 2)}`, 320: } 321: } 322: function parseHookOutput(stdout: string): { 323: json?: HookJSONOutput 324: plainText?: string 325: validationError?: string 326: } { 327: const trimmed = stdout.trim() 328: if (!trimmed.startsWith('{')) { 329: logForDebugging('Hook output does not start with {, treating as plain text') 330: return { plainText: stdout } 331: } 332: try { 333: const result = validateHookJson(trimmed) 334: if ('json' in result) { 335: return result 336: } 337: const errorMessage = `${result.validationError}\n\nExpected schema:\n${jsonStringify( 338: { 339: continue: 'boolean (optional)', 340: suppressOutput: 'boolean (optional)', 341: stopReason: 'string (optional)', 342: decision: '"approve" | "block" (optional)', 343: reason: 'string (optional)', 344: systemMessage: 'string (optional)', 345: permissionDecision: '"allow" | "deny" | "ask" (optional)', 346: hookSpecificOutput: { 347: 'for PreToolUse': { 348: hookEventName: '"PreToolUse"', 349: permissionDecision: '"allow" | "deny" | "ask" (optional)', 350: permissionDecisionReason: 'string (optional)', 351: updatedInput: 'object (optional) - Modified tool input to use', 352: }, 353: 'for UserPromptSubmit': { 354: hookEventName: '"UserPromptSubmit"', 355: additionalContext: 'string (required)', 356: }, 357: 'for PostToolUse': { 358: hookEventName: '"PostToolUse"', 359: additionalContext: 'string (optional)', 360: }, 361: }, 362: }, 363: null, 364: 2, 365: )}` 366: logForDebugging(errorMessage) 367: return { plainText: stdout, validationError: errorMessage } 368: } catch (e) { 369: logForDebugging(`Failed to parse hook output as JSON: ${e}`) 370: return { plainText: stdout } 371: } 372: } 373: function parseHttpHookOutput(body: string): { 374: json?: HookJSONOutput 375: validationError?: string 376: } { 377: const trimmed = body.trim() 378: if (trimmed === '') { 379: const validation = hookJSONOutputSchema().safeParse({}) 380: if (validation.success) { 381: logForDebugging( 382: 'HTTP hook returned empty body, treating as empty JSON object', 383: ) 384: return { json: validation.data } 385: } 386: } 387: if (!trimmed.startsWith('{')) { 388: const validationError = `HTTP hook must return JSON, but got non-JSON response body: ${trimmed.length > 200 ? trimmed.slice(0, 200) + '\u2026' : trimmed}` 389: logForDebugging(validationError) 390: return { validationError } 391: } 392: try { 393: const result = validateHookJson(trimmed) 394: if ('json' in result) { 395: return result 396: } 397: logForDebugging(result.validationError) 398: return result 399: } catch (e) { 400: const validationError = `HTTP hook must return valid JSON, but parsing failed: ${e}` 401: logForDebugging(validationError) 402: return { validationError } 403: } 404: } 405: function processHookJSONOutput({ 406: json, 407: command, 408: hookName, 409: toolUseID, 410: hookEvent, 411: expectedHookEvent, 412: stdout, 413: stderr, 414: exitCode, 415: durationMs, 416: }: { 417: json: SyncHookJSONOutput 418: command: string 419: hookName: string 420: toolUseID: string 421: hookEvent: HookEvent 422: expectedHookEvent?: HookEvent 423: stdout?: string 424: stderr?: string 425: exitCode?: number 426: durationMs?: number 427: }): Partial<HookResult> { 428: const result: Partial<HookResult> = {} 429: const syncJson = json 430: if (syncJson.continue === false) { 431: result.preventContinuation = true 432: if (syncJson.stopReason) { 433: result.stopReason = syncJson.stopReason 434: } 435: } 436: if (json.decision) { 437: switch (json.decision) { 438: case 'approve': 439: result.permissionBehavior = 'allow' 440: break 441: case 'block': 442: result.permissionBehavior = 'deny' 443: result.blockingError = { 444: blockingError: json.reason || 'Blocked by hook', 445: command, 446: } 447: break 448: default: 449: throw new Error( 450: `Unknown hook decision type: ${json.decision}. Valid types are: approve, block`, 451: ) 452: } 453: } 454: if (json.systemMessage) { 455: result.systemMessage = json.systemMessage 456: } 457: if ( 458: json.hookSpecificOutput?.hookEventName === 'PreToolUse' && 459: json.hookSpecificOutput.permissionDecision 460: ) { 461: switch (json.hookSpecificOutput.permissionDecision) { 462: case 'allow': 463: result.permissionBehavior = 'allow' 464: break 465: case 'deny': 466: result.permissionBehavior = 'deny' 467: result.blockingError = { 468: blockingError: json.reason || 'Blocked by hook', 469: command, 470: } 471: break 472: case 'ask': 473: result.permissionBehavior = 'ask' 474: break 475: default: 476: throw new Error( 477: `Unknown hook permissionDecision type: ${json.hookSpecificOutput.permissionDecision}. Valid types are: allow, deny, ask`, 478: ) 479: } 480: } 481: if (result.permissionBehavior !== undefined && json.reason !== undefined) { 482: result.hookPermissionDecisionReason = json.reason 483: } 484: if (json.hookSpecificOutput) { 485: if ( 486: expectedHookEvent && 487: json.hookSpecificOutput.hookEventName !== expectedHookEvent 488: ) { 489: throw new Error( 490: `Hook returned incorrect event name: expected '${expectedHookEvent}' but got '${json.hookSpecificOutput.hookEventName}'. Full stdout: ${jsonStringify(json, null, 2)}`, 491: ) 492: } 493: switch (json.hookSpecificOutput.hookEventName) { 494: case 'PreToolUse': 495: if (json.hookSpecificOutput.permissionDecision) { 496: switch (json.hookSpecificOutput.permissionDecision) { 497: case 'allow': 498: result.permissionBehavior = 'allow' 499: break 500: case 'deny': 501: result.permissionBehavior = 'deny' 502: result.blockingError = { 503: blockingError: 504: json.hookSpecificOutput.permissionDecisionReason || 505: json.reason || 506: 'Blocked by hook', 507: command, 508: } 509: break 510: case 'ask': 511: result.permissionBehavior = 'ask' 512: break 513: } 514: } 515: result.hookPermissionDecisionReason = 516: json.hookSpecificOutput.permissionDecisionReason 517: if (json.hookSpecificOutput.updatedInput) { 518: result.updatedInput = json.hookSpecificOutput.updatedInput 519: } 520: result.additionalContext = json.hookSpecificOutput.additionalContext 521: break 522: case 'UserPromptSubmit': 523: result.additionalContext = json.hookSpecificOutput.additionalContext 524: break 525: case 'SessionStart': 526: result.additionalContext = json.hookSpecificOutput.additionalContext 527: result.initialUserMessage = json.hookSpecificOutput.initialUserMessage 528: if ( 529: 'watchPaths' in json.hookSpecificOutput && 530: json.hookSpecificOutput.watchPaths 531: ) { 532: result.watchPaths = json.hookSpecificOutput.watchPaths 533: } 534: break 535: case 'Setup': 536: result.additionalContext = json.hookSpecificOutput.additionalContext 537: break 538: case 'SubagentStart': 539: result.additionalContext = json.hookSpecificOutput.additionalContext 540: break 541: case 'PostToolUse': 542: result.additionalContext = json.hookSpecificOutput.additionalContext 543: if (json.hookSpecificOutput.updatedMCPToolOutput) { 544: result.updatedMCPToolOutput = 545: json.hookSpecificOutput.updatedMCPToolOutput 546: } 547: break 548: case 'PostToolUseFailure': 549: result.additionalContext = json.hookSpecificOutput.additionalContext 550: break 551: case 'PermissionDenied': 552: result.retry = json.hookSpecificOutput.retry 553: break 554: case 'PermissionRequest': 555: if (json.hookSpecificOutput.decision) { 556: result.permissionRequestResult = json.hookSpecificOutput.decision 557: result.permissionBehavior = 558: json.hookSpecificOutput.decision.behavior === 'allow' 559: ? 'allow' 560: : 'deny' 561: if ( 562: json.hookSpecificOutput.decision.behavior === 'allow' && 563: json.hookSpecificOutput.decision.updatedInput 564: ) { 565: result.updatedInput = json.hookSpecificOutput.decision.updatedInput 566: } 567: } 568: break 569: case 'Elicitation': 570: if (json.hookSpecificOutput.action) { 571: result.elicitationResponse = { 572: action: json.hookSpecificOutput.action, 573: content: json.hookSpecificOutput.content as 574: | ElicitationResponse['content'] 575: | undefined, 576: } 577: if (json.hookSpecificOutput.action === 'decline') { 578: result.blockingError = { 579: blockingError: json.reason || 'Elicitation denied by hook', 580: command, 581: } 582: } 583: } 584: break 585: case 'ElicitationResult': 586: if (json.hookSpecificOutput.action) { 587: result.elicitationResultResponse = { 588: action: json.hookSpecificOutput.action, 589: content: json.hookSpecificOutput.content as 590: | ElicitationResponse['content'] 591: | undefined, 592: } 593: if (json.hookSpecificOutput.action === 'decline') { 594: result.blockingError = { 595: blockingError: 596: json.reason || 'Elicitation result blocked by hook', 597: command, 598: } 599: } 600: } 601: break 602: } 603: } 604: return { 605: ...result, 606: message: result.blockingError 607: ? createAttachmentMessage({ 608: type: 'hook_blocking_error', 609: hookName, 610: toolUseID, 611: hookEvent, 612: blockingError: result.blockingError, 613: }) 614: : createAttachmentMessage({ 615: type: 'hook_success', 616: hookName, 617: toolUseID, 618: hookEvent, 619: content: '', 620: stdout, 621: stderr, 622: exitCode, 623: command, 624: durationMs, 625: }), 626: } 627: } 628: /** 629: * Execute a command-based hook using bash or PowerShell. 630: * 631: * Shell resolution: hook.shell → 'bash'. PowerShell hooks spawn pwsh 632: * with -NoProfile -NonInteractive -Command and skip bash-specific prep 633: * (POSIX path conversion, .sh auto-prepend, CLAUDE_CODE_SHELL_PREFIX). 634: * See docs/design/ps-shell-selection.md §5.1. 635: */ 636: async function execCommandHook( 637: hook: HookCommand & { type: 'command' }, 638: hookEvent: HookEvent | 'StatusLine' | 'FileSuggestion', 639: hookName: string, 640: jsonInput: string, 641: signal: AbortSignal, 642: hookId: string, 643: hookIndex?: number, 644: pluginRoot?: string, 645: pluginId?: string, 646: skillRoot?: string, 647: forceSyncExecution?: boolean, 648: requestPrompt?: (request: PromptRequest) => Promise<PromptResponse>, 649: ): Promise<{ 650: stdout: string 651: stderr: string 652: output: string 653: status: number 654: aborted?: boolean 655: backgrounded?: boolean 656: }> { 657: const shouldEmitDiag = 658: hookEvent === 'SessionStart' || 659: hookEvent === 'Setup' || 660: hookEvent === 'SessionEnd' 661: const diagStartMs = Date.now() 662: let diagExitCode: number | undefined 663: let diagAborted = false 664: const isWindows = getPlatform() === 'windows' 665: const shellType = hook.shell ?? DEFAULT_HOOK_SHELL 666: const isPowerShell = shellType === 'powershell' 667: const toHookPath = 668: isWindows && !isPowerShell 669: ? (p: string) => windowsPathToPosixPath(p) 670: : (p: string) => p 671: const projectDir = getProjectRoot() 672: let command = hook.command 673: let pluginOpts: ReturnType<typeof loadPluginOptions> | undefined 674: if (pluginRoot) { 675: if (!(await pathExists(pluginRoot))) { 676: throw new Error( 677: `Plugin directory does not exist: ${pluginRoot}` + 678: (pluginId ? ` (${pluginId} — run /plugin to reinstall)` : ''), 679: ) 680: } 681: // Inline both ROOT and DATA substitution instead of calling 682: // substitutePluginVariables(). That helper normalizes \ → / on Windows 683: // unconditionally — correct for bash (toHookPath already produced /c/... 684: // so it's a no-op) but wrong for PS where toHookPath is identity and we 685: const rootPath = toHookPath(pluginRoot) 686: command = command.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, () => rootPath) 687: if (pluginId) { 688: const dataPath = toHookPath(getPluginDataDir(pluginId)) 689: command = command.replace(/\$\{CLAUDE_PLUGIN_DATA\}/g, () => dataPath) 690: } 691: if (pluginId) { 692: pluginOpts = loadPluginOptions(pluginId) 693: command = substituteUserConfigVariables(command, pluginOpts) 694: } 695: } 696: if (isWindows && !isPowerShell && command.trim().match(/\.sh(\s|$|")/)) { 697: if (!command.trim().startsWith('bash ')) { 698: command = `bash ${command}` 699: } 700: } 701: // CLAUDE_CODE_SHELL_PREFIX wraps the command via POSIX quoting 702: // (formatShellPrefixCommand uses shell-quote). This makes no sense for 703: // PowerShell — see design §8.1. For now PS hooks ignore the prefix; 704: // a CLAUDE_CODE_PS_SHELL_PREFIX (or shell-aware prefix) is a follow-up. 705: const finalCommand = 706: !isPowerShell && process.env.CLAUDE_CODE_SHELL_PREFIX 707: ? formatShellPrefixCommand(process.env.CLAUDE_CODE_SHELL_PREFIX, command) 708: : command 709: const hookTimeoutMs = hook.timeout 710: ? hook.timeout * 1000 711: : TOOL_HOOK_EXECUTION_TIMEOUT_MS 712: // Build env vars — all paths go through toHookPath for Windows POSIX conversion 713: const envVars: NodeJS.ProcessEnv = { 714: ...subprocessEnv(), 715: CLAUDE_PROJECT_DIR: toHookPath(projectDir), 716: } 717: // Plugin and skill hooks both set CLAUDE_PLUGIN_ROOT (skills use the same 718: // name for consistency — skills can migrate to plugins without code changes) 719: if (pluginRoot) { 720: envVars.CLAUDE_PLUGIN_ROOT = toHookPath(pluginRoot) 721: if (pluginId) { 722: envVars.CLAUDE_PLUGIN_DATA = toHookPath(getPluginDataDir(pluginId)) 723: } 724: } 725: // Expose plugin options as env vars too, so hooks can read them without 726: // ${user_config.X} in the command string. Sensitive values included — hooks 727: // run the user's own code, same trust boundary as reading keychain directly. 728: if (pluginOpts) { 729: for (const [key, value] of Object.entries(pluginOpts)) { 730: // Sanitize non-identifier chars (bash can't ref $FOO-BAR). The schema 731: // at schemas.ts:611 now constrains keys to /^[A-Za-z_]\w*$/ so this is 732: // belt-and-suspenders, but cheap insurance if someone bypasses the schema. 733: const envKey = key.replace(/[^A-Za-z0-9_]/g, '_').toUpperCase() 734: envVars[`CLAUDE_PLUGIN_OPTION_${envKey}`] = String(value) 735: } 736: } 737: if (skillRoot) { 738: envVars.CLAUDE_PLUGIN_ROOT = toHookPath(skillRoot) 739: } 740: // CLAUDE_ENV_FILE points to a .sh file that the hook writes env var 741: // definitions into; getSessionEnvironmentScript() concatenates them and 742: // bashProvider injects the content into bash commands. A PS hook would 743: // naturally write PS syntax ($env:FOO = 'bar'), which bash can't parse. 744: // Skip for PS — consistent with how .sh prepend and SHELL_PREFIX are 745: // already bash-only above. 746: if ( 747: !isPowerShell && 748: (hookEvent === 'SessionStart' || 749: hookEvent === 'Setup' || 750: hookEvent === 'CwdChanged' || 751: hookEvent === 'FileChanged') && 752: hookIndex !== undefined 753: ) { 754: envVars.CLAUDE_ENV_FILE = await getHookEnvFilePath(hookEvent, hookIndex) 755: } 756: // When agent worktrees are removed, getCwd() may return a deleted path via 757: // AsyncLocalStorage. Validate before spawning since spawn() emits async 758: // 'error' events for missing cwd rather than throwing synchronously. 759: const hookCwd = getCwd() 760: const safeCwd = (await pathExists(hookCwd)) ? hookCwd : getOriginalCwd() 761: if (safeCwd !== hookCwd) { 762: logForDebugging( 763: `Hooks: cwd ${hookCwd} not found, falling back to original cwd`, 764: { level: 'warn' }, 765: ) 766: } 767: // -- 768: // Spawn. Two completely separate paths: 769: // 770: // Bash: spawn(cmd, [], { shell: <gitBashPath | true> }) — the shell 771: // option makes Node pass the whole string to the shell for parsing. 772: // 773: // PowerShell: spawn(pwshPath, ['-NoProfile', '-NonInteractive', 774: // '-Command', cmd]) — explicit argv, no shell option. -NoProfile 775: // skips user profile scripts (faster, deterministic). 776: // -NonInteractive fails fast instead of prompting. 777: // 778: // The Git Bash hard-exit in findGitBashPath() is still in place for 779: // bash hooks. PowerShell hooks never call it, so a Windows user with 780: // only pwsh and shell: 'powershell' on every hook could in theory run 781: // without Git Bash — but init.ts still calls setShellIfWindows() on 782: // startup, which will exit first. Relaxing that is phase 1 of the 783: // design's implementation order (separate PR). 784: let child: ChildProcessWithoutNullStreams 785: if (shellType === 'powershell') { 786: const pwshPath = await getCachedPowerShellPath() 787: if (!pwshPath) { 788: throw new Error( 789: `Hook "${hook.command}" has shell: 'powershell' but no PowerShell ` + 790: `executable (pwsh or powershell) was found on PATH. Install ` + 791: `PowerShell, or remove "shell": "powershell" to use bash.`, 792: ) 793: } 794: child = spawn(pwshPath, buildPowerShellArgs(finalCommand), { 795: env: envVars, 796: cwd: safeCwd, 797: // Prevent visible console window on Windows (no-op on other platforms) 798: windowsHide: true, 799: }) as ChildProcessWithoutNullStreams 800: } else { 801: // On Windows, use Git Bash explicitly (cmd.exe can't run bash syntax). 802: // On other platforms, shell: true uses /bin/sh. 803: const shell = isWindows ? findGitBashPath() : true 804: child = spawn(finalCommand, [], { 805: env: envVars, 806: cwd: safeCwd, 807: shell, 808: // Prevent visible console window on Windows (no-op on other platforms) 809: windowsHide: true, 810: }) as ChildProcessWithoutNullStreams 811: } 812: // Hooks use pipe mode — stdout must be streamed into JS so we can parse 813: // the first response line to detect async hooks ({"async": true}). 814: const hookTaskOutput = new TaskOutput(`hook_${child.pid}`, null) 815: const shellCommand = wrapSpawn(child, signal, hookTimeoutMs, hookTaskOutput) 816: // Track whether shellCommand ownership was transferred (e.g., to async hook registry) 817: let shellCommandTransferred = false 818: // Track whether stdin has already been written (to avoid "write after end" errors) 819: let stdinWritten = false 820: if ((hook.async || hook.asyncRewake) && !forceSyncExecution) { 821: const processId = `async_hook_${child.pid}` 822: logForDebugging( 823: `Hooks: Config-based async hook, backgrounding process ${processId}`, 824: ) 825: // Write stdin before backgrounding so the hook receives its input. 826: // The trailing newline matches the sync path (L1000). Without it, 827: // bash `read -r line` returns exit 1 (EOF before delimiter) — the 828: child.stdin.write(jsonInput + '\n', 'utf8') 829: child.stdin.end() 830: stdinWritten = true 831: const backgrounded = executeInBackground({ 832: processId, 833: hookId, 834: shellCommand, 835: asyncResponse: { async: true, asyncTimeout: hookTimeoutMs }, 836: hookEvent, 837: hookName, 838: command: hook.command, 839: asyncRewake: hook.asyncRewake, 840: pluginId, 841: }) 842: if (backgrounded) { 843: return { 844: stdout: '', 845: stderr: '', 846: output: '', 847: status: 0, 848: backgrounded: true, 849: } 850: } 851: } 852: let stdout = '' 853: let stderr = '' 854: let output = '' 855: // Set up output data collection with explicit UTF-8 encoding 856: child.stdout.setEncoding('utf8') 857: child.stderr.setEncoding('utf8') 858: let initialResponseChecked = false 859: let asyncResolve: 860: | ((result: { 861: stdout: string 862: stderr: string 863: output: string 864: status: number 865: }) => void) 866: | null = null 867: const childIsAsyncPromise = new Promise<{ 868: stdout: string 869: stderr: string 870: output: string 871: status: number 872: aborted?: boolean 873: }>(resolve => { 874: asyncResolve = resolve 875: }) 876: const processedPromptLines = new Set<string>() 877: let promptChain = Promise.resolve() 878: let lineBuffer = '' 879: child.stdout.on('data', data => { 880: stdout += data 881: output += data 882: if (requestPrompt) { 883: lineBuffer += data 884: const lines = lineBuffer.split('\n') 885: lineBuffer = lines.pop() ?? '' // last element is an incomplete line 886: for (const line of lines) { 887: const trimmed = line.trim() 888: if (!trimmed) continue 889: try { 890: const parsed = jsonParse(trimmed) 891: const validation = promptRequestSchema().safeParse(parsed) 892: if (validation.success) { 893: processedPromptLines.add(trimmed) 894: logForDebugging( 895: `Hooks: Detected prompt request from hook: ${trimmed}`, 896: ) 897: // Chain the async handling to serialize prompt responses 898: const promptReq = validation.data 899: const reqPrompt = requestPrompt 900: promptChain = promptChain.then(async () => { 901: try { 902: const response = await reqPrompt(promptReq) 903: child.stdin.write(jsonStringify(response) + '\n', 'utf8') 904: } catch (err) { 905: logForDebugging(`Hooks: Prompt request handling failed: ${err}`) 906: child.stdin.destroy() 907: } 908: }) 909: continue 910: } 911: } catch { 912: } 913: } 914: } 915: if (!initialResponseChecked) { 916: const firstLine = firstLineOf(stdout).trim() 917: if (!firstLine.includes('}')) return 918: initialResponseChecked = true 919: logForDebugging(`Hooks: Checking first line for async: ${firstLine}`) 920: try { 921: const parsed = jsonParse(firstLine) 922: logForDebugging( 923: `Hooks: Parsed initial response: ${jsonStringify(parsed)}`, 924: ) 925: if (isAsyncHookJSONOutput(parsed) && !forceSyncExecution) { 926: const processId = `async_hook_${child.pid}` 927: logForDebugging( 928: `Hooks: Detected async hook, backgrounding process ${processId}`, 929: ) 930: const backgrounded = executeInBackground({ 931: processId, 932: hookId, 933: shellCommand, 934: asyncResponse: parsed, 935: hookEvent, 936: hookName, 937: command: hook.command, 938: pluginId, 939: }) 940: if (backgrounded) { 941: shellCommandTransferred = true 942: asyncResolve?.({ 943: stdout, 944: stderr, 945: output, 946: status: 0, 947: }) 948: } 949: } else if (isAsyncHookJSONOutput(parsed) && forceSyncExecution) { 950: logForDebugging( 951: `Hooks: Detected async hook but forceSyncExecution is true, waiting for completion`, 952: ) 953: } else { 954: logForDebugging( 955: `Hooks: Initial response is not async, continuing normal processing`, 956: ) 957: } 958: } catch (e) { 959: logForDebugging(`Hooks: Failed to parse initial response as JSON: ${e}`) 960: } 961: } 962: }) 963: child.stderr.on('data', data => { 964: stderr += data 965: output += data 966: }) 967: const stopProgressInterval = startHookProgressInterval({ 968: hookId, 969: hookName, 970: hookEvent, 971: getOutput: async () => ({ stdout, stderr, output }), 972: }) 973: const stdoutEndPromise = new Promise<void>(resolve => { 974: child.stdout.on('end', () => resolve()) 975: }) 976: const stderrEndPromise = new Promise<void>(resolve => { 977: child.stderr.on('end', () => resolve()) 978: }) 979: const stdinWritePromise = stdinWritten 980: ? Promise.resolve() 981: : new Promise<void>((resolve, reject) => { 982: child.stdin.on('error', err => { 983: if (!requestPrompt) { 984: reject(err) 985: } else { 986: logForDebugging( 987: `Hooks: stdin error during prompt flow (likely process exited): ${err}`, 988: ) 989: } 990: }) 991: child.stdin.write(jsonInput + '\n', 'utf8') 992: if (!requestPrompt) { 993: child.stdin.end() 994: } 995: resolve() 996: }) 997: const childErrorPromise = new Promise<never>((_, reject) => { 998: child.on('error', reject) 999: }) 1000: const childClosePromise = new Promise<{ 1001: stdout: string 1002: stderr: string 1003: output: string 1004: status: number 1005: aborted?: boolean 1006: }>(resolve => { 1007: let exitCode: number | null = null 1008: child.on('close', code => { 1009: exitCode = code ?? 1 1010: void Promise.all([stdoutEndPromise, stderrEndPromise]).then(() => { 1011: const finalStdout = 1012: processedPromptLines.size === 0 1013: ? stdout 1014: : stdout 1015: .split('\n') 1016: .filter(line => !processedPromptLines.has(line.trim())) 1017: .join('\n') 1018: resolve({ 1019: stdout: finalStdout, 1020: stderr, 1021: output, 1022: status: exitCode!, 1023: aborted: signal.aborted, 1024: }) 1025: }) 1026: }) 1027: }) 1028: try { 1029: if (shouldEmitDiag) { 1030: logForDiagnosticsNoPII('info', 'hook_spawn_started', { 1031: hook_event_name: hookEvent, 1032: index: hookIndex, 1033: }) 1034: } 1035: await Promise.race([stdinWritePromise, childErrorPromise]) 1036: const result = await Promise.race([ 1037: childIsAsyncPromise, 1038: childClosePromise, 1039: childErrorPromise, 1040: ]) 1041: await promptChain 1042: diagExitCode = result.status 1043: diagAborted = result.aborted ?? false 1044: return result 1045: } catch (error) { 1046: const code = getErrnoCode(error) 1047: diagExitCode = 1 1048: if (code === 'EPIPE') { 1049: logForDebugging( 1050: 'EPIPE error while writing to hook stdin (hook command likely closed early)', 1051: ) 1052: const errMsg = 1053: 'Hook command closed stdin before hook input was fully written (EPIPE)' 1054: return { 1055: stdout: '', 1056: stderr: errMsg, 1057: output: errMsg, 1058: status: 1, 1059: } 1060: } else if (code === 'ABORT_ERR') { 1061: diagAborted = true 1062: return { 1063: stdout: '', 1064: stderr: 'Hook cancelled', 1065: output: 'Hook cancelled', 1066: status: 1, 1067: aborted: true, 1068: } 1069: } else { 1070: const errorMsg = errorMessage(error) 1071: const errOutput = `Error occurred while executing hook command: ${errorMsg}` 1072: return { 1073: stdout: '', 1074: stderr: errOutput, 1075: output: errOutput, 1076: status: 1, 1077: } 1078: } 1079: } finally { 1080: if (shouldEmitDiag) { 1081: logForDiagnosticsNoPII('info', 'hook_spawn_completed', { 1082: hook_event_name: hookEvent, 1083: index: hookIndex, 1084: duration_ms: Date.now() - diagStartMs, 1085: exit_code: diagExitCode, 1086: aborted: diagAborted, 1087: }) 1088: } 1089: stopProgressInterval() 1090: if (!shellCommandTransferred) { 1091: shellCommand.cleanup() 1092: } 1093: } 1094: } 1095: function matchesPattern(matchQuery: string, matcher: string): boolean { 1096: if (!matcher || matcher === '*') { 1097: return true 1098: } 1099: if (/^[a-zA-Z0-9_|]+$/.test(matcher)) { 1100: if (matcher.includes('|')) { 1101: const patterns = matcher 1102: .split('|') 1103: .map(p => normalizeLegacyToolName(p.trim())) 1104: return patterns.includes(matchQuery) 1105: } 1106: return matchQuery === normalizeLegacyToolName(matcher) 1107: } 1108: try { 1109: const regex = new RegExp(matcher) 1110: if (regex.test(matchQuery)) { 1111: return true 1112: } 1113: for (const legacyName of getLegacyToolNames(matchQuery)) { 1114: if (regex.test(legacyName)) { 1115: return true 1116: } 1117: } 1118: return false 1119: } catch { 1120: logForDebugging(`Invalid regex pattern in hook matcher: ${matcher}`) 1121: return false 1122: } 1123: } 1124: type IfConditionMatcher = (ifCondition: string) => boolean 1125: async function prepareIfConditionMatcher( 1126: hookInput: HookInput, 1127: tools: Tools | undefined, 1128: ): Promise<IfConditionMatcher | undefined> { 1129: if ( 1130: hookInput.hook_event_name !== 'PreToolUse' && 1131: hookInput.hook_event_name !== 'PostToolUse' && 1132: hookInput.hook_event_name !== 'PostToolUseFailure' && 1133: hookInput.hook_event_name !== 'PermissionRequest' 1134: ) { 1135: return undefined 1136: } 1137: const toolName = normalizeLegacyToolName(hookInput.tool_name) 1138: const tool = tools && findToolByName(tools, hookInput.tool_name) 1139: const input = tool?.inputSchema.safeParse(hookInput.tool_input) 1140: const patternMatcher = 1141: input?.success && tool?.preparePermissionMatcher 1142: ? await tool.preparePermissionMatcher(input.data) 1143: : undefined 1144: return ifCondition => { 1145: const parsed = permissionRuleValueFromString(ifCondition) 1146: if (normalizeLegacyToolName(parsed.toolName) !== toolName) { 1147: return false 1148: } 1149: if (!parsed.ruleContent) { 1150: return true 1151: } 1152: return patternMatcher ? patternMatcher(parsed.ruleContent) : false 1153: } 1154: } 1155: type FunctionHookMatcher = { 1156: matcher: string 1157: hooks: FunctionHook[] 1158: } 1159: type MatchedHook = { 1160: hook: HookCommand | HookCallback | FunctionHook 1161: pluginRoot?: string 1162: pluginId?: string 1163: skillRoot?: string 1164: hookSource?: string 1165: } 1166: function isInternalHook(matched: MatchedHook): boolean { 1167: return matched.hook.type === 'callback' && matched.hook.internal === true 1168: } 1169: function hookDedupKey(m: MatchedHook, payload: string): string { 1170: return `${m.pluginRoot ?? m.skillRoot ?? ''}\0${payload}` 1171: } 1172: function getPluginHookCounts( 1173: hooks: MatchedHook[], 1174: ): Record<string, number> | undefined { 1175: const pluginHooks = hooks.filter(h => h.pluginId) 1176: if (pluginHooks.length === 0) { 1177: return undefined 1178: } 1179: const counts: Record<string, number> = {} 1180: for (const h of pluginHooks) { 1181: const atIndex = h.pluginId!.lastIndexOf('@') 1182: const isOfficial = 1183: atIndex > 0 && 1184: ALLOWED_OFFICIAL_MARKETPLACE_NAMES.has(h.pluginId!.slice(atIndex + 1)) 1185: const key = isOfficial ? h.pluginId! : 'third-party' 1186: counts[key] = (counts[key] || 0) + 1 1187: } 1188: return counts 1189: } 1190: function getHookTypeCounts(hooks: MatchedHook[]): Record<string, number> { 1191: const counts: Record<string, number> = {} 1192: for (const h of hooks) { 1193: counts[h.hook.type] = (counts[h.hook.type] || 0) + 1 1194: } 1195: return counts 1196: } 1197: function getHooksConfig( 1198: appState: AppState | undefined, 1199: sessionId: string, 1200: hookEvent: HookEvent, 1201: ): Array< 1202: | HookMatcher 1203: | HookCallbackMatcher 1204: | FunctionHookMatcher 1205: | PluginHookMatcher 1206: | SkillHookMatcher 1207: | SessionDerivedHookMatcher 1208: > { 1209: const hooks: Array< 1210: | HookMatcher 1211: | HookCallbackMatcher 1212: | FunctionHookMatcher 1213: | PluginHookMatcher 1214: | SkillHookMatcher 1215: | SessionDerivedHookMatcher 1216: > = [...(getHooksConfigFromSnapshot()?.[hookEvent] ?? [])] 1217: const managedOnly = shouldAllowManagedHooksOnly() 1218: const registeredHooks = getRegisteredHooks()?.[hookEvent] 1219: if (registeredHooks) { 1220: for (const matcher of registeredHooks) { 1221: if (managedOnly && 'pluginRoot' in matcher) { 1222: continue 1223: } 1224: hooks.push(matcher) 1225: } 1226: } 1227: if (!managedOnly && appState !== undefined) { 1228: const sessionHooks = getSessionHooks(appState, sessionId, hookEvent).get( 1229: hookEvent, 1230: ) 1231: if (sessionHooks) { 1232: for (const matcher of sessionHooks) { 1233: hooks.push(matcher) 1234: } 1235: } 1236: const sessionFunctionHooks = getSessionFunctionHooks( 1237: appState, 1238: sessionId, 1239: hookEvent, 1240: ).get(hookEvent) 1241: if (sessionFunctionHooks) { 1242: for (const matcher of sessionFunctionHooks) { 1243: hooks.push(matcher) 1244: } 1245: } 1246: } 1247: return hooks 1248: } 1249: function hasHookForEvent( 1250: hookEvent: HookEvent, 1251: appState: AppState | undefined, 1252: sessionId: string, 1253: ): boolean { 1254: const snap = getHooksConfigFromSnapshot()?.[hookEvent] 1255: if (snap && snap.length > 0) return true 1256: const reg = getRegisteredHooks()?.[hookEvent] 1257: if (reg && reg.length > 0) return true 1258: if (appState?.sessionHooks.get(sessionId)?.hooks[hookEvent]) return true 1259: return false 1260: } 1261: export async function getMatchingHooks( 1262: appState: AppState | undefined, 1263: sessionId: string, 1264: hookEvent: HookEvent, 1265: hookInput: HookInput, 1266: tools?: Tools, 1267: ): Promise<MatchedHook[]> { 1268: try { 1269: const hookMatchers = getHooksConfig(appState, sessionId, hookEvent) 1270: let matchQuery: string | undefined = undefined 1271: switch (hookInput.hook_event_name) { 1272: case 'PreToolUse': 1273: case 'PostToolUse': 1274: case 'PostToolUseFailure': 1275: case 'PermissionRequest': 1276: case 'PermissionDenied': 1277: matchQuery = hookInput.tool_name 1278: break 1279: case 'SessionStart': 1280: matchQuery = hookInput.source 1281: break 1282: case 'Setup': 1283: matchQuery = hookInput.trigger 1284: break 1285: case 'PreCompact': 1286: case 'PostCompact': 1287: matchQuery = hookInput.trigger 1288: break 1289: case 'Notification': 1290: matchQuery = hookInput.notification_type 1291: break 1292: case 'SessionEnd': 1293: matchQuery = hookInput.reason 1294: break 1295: case 'StopFailure': 1296: matchQuery = hookInput.error 1297: break 1298: case 'SubagentStart': 1299: matchQuery = hookInput.agent_type 1300: break 1301: case 'SubagentStop': 1302: matchQuery = hookInput.agent_type 1303: break 1304: case 'TeammateIdle': 1305: case 'TaskCreated': 1306: case 'TaskCompleted': 1307: break 1308: case 'Elicitation': 1309: matchQuery = hookInput.mcp_server_name 1310: break 1311: case 'ElicitationResult': 1312: matchQuery = hookInput.mcp_server_name 1313: break 1314: case 'ConfigChange': 1315: matchQuery = hookInput.source 1316: break 1317: case 'InstructionsLoaded': 1318: matchQuery = hookInput.load_reason 1319: break 1320: case 'FileChanged': 1321: matchQuery = basename(hookInput.file_path) 1322: break 1323: default: 1324: break 1325: } 1326: logForDebugging( 1327: `Getting matching hook commands for ${hookEvent} with query: ${matchQuery}`, 1328: { level: 'verbose' }, 1329: ) 1330: logForDebugging(`Found ${hookMatchers.length} hook matchers in settings`, { 1331: level: 'verbose', 1332: }) 1333: const filteredMatchers = matchQuery 1334: ? hookMatchers.filter( 1335: matcher => 1336: !matcher.matcher || matchesPattern(matchQuery, matcher.matcher), 1337: ) 1338: : hookMatchers 1339: const matchedHooks: MatchedHook[] = filteredMatchers.flatMap(matcher => { 1340: const pluginRoot = 1341: 'pluginRoot' in matcher ? matcher.pluginRoot : undefined 1342: const pluginId = 'pluginId' in matcher ? matcher.pluginId : undefined 1343: const skillRoot = 'skillRoot' in matcher ? matcher.skillRoot : undefined 1344: const hookSource = pluginRoot 1345: ? 'pluginName' in matcher 1346: ? `plugin:${matcher.pluginName}` 1347: : 'plugin' 1348: : skillRoot 1349: ? 'skillName' in matcher 1350: ? `skill:${matcher.skillName}` 1351: : 'skill' 1352: : 'settings' 1353: return matcher.hooks.map(hook => ({ 1354: hook, 1355: pluginRoot, 1356: pluginId, 1357: skillRoot, 1358: hookSource, 1359: })) 1360: }) 1361: if ( 1362: matchedHooks.every( 1363: m => m.hook.type === 'callback' || m.hook.type === 'function', 1364: ) 1365: ) { 1366: return matchedHooks 1367: } 1368: const getIfCondition = (hook: { if?: string }): string => hook.if ?? '' 1369: const uniqueCommandHooks = Array.from( 1370: new Map( 1371: matchedHooks 1372: .filter( 1373: ( 1374: m, 1375: ): m is MatchedHook & { hook: HookCommand & { type: 'command' } } => 1376: m.hook.type === 'command', 1377: ) 1378: .map(m => [ 1379: hookDedupKey( 1380: m, 1381: `${m.hook.shell ?? DEFAULT_HOOK_SHELL}\0${m.hook.command}\0${getIfCondition(m.hook)}`, 1382: ), 1383: m, 1384: ]), 1385: ).values(), 1386: ) 1387: const uniquePromptHooks = Array.from( 1388: new Map( 1389: matchedHooks 1390: .filter(m => m.hook.type === 'prompt') 1391: .map(m => [ 1392: hookDedupKey( 1393: m, 1394: `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, 1395: ), 1396: m, 1397: ]), 1398: ).values(), 1399: ) 1400: const uniqueAgentHooks = Array.from( 1401: new Map( 1402: matchedHooks 1403: .filter(m => m.hook.type === 'agent') 1404: .map(m => [ 1405: hookDedupKey( 1406: m, 1407: `${(m.hook as { prompt: string }).prompt}\0${getIfCondition(m.hook as { if?: string })}`, 1408: ), 1409: m, 1410: ]), 1411: ).values(), 1412: ) 1413: const uniqueHttpHooks = Array.from( 1414: new Map( 1415: matchedHooks 1416: .filter(m => m.hook.type === 'http') 1417: .map(m => [ 1418: hookDedupKey( 1419: m, 1420: `${(m.hook as { url: string }).url}\0${getIfCondition(m.hook as { if?: string })}`, 1421: ), 1422: m, 1423: ]), 1424: ).values(), 1425: ) 1426: const callbackHooks = matchedHooks.filter(m => m.hook.type === 'callback') 1427: const functionHooks = matchedHooks.filter(m => m.hook.type === 'function') 1428: const uniqueHooks = [ 1429: ...uniqueCommandHooks, 1430: ...uniquePromptHooks, 1431: ...uniqueAgentHooks, 1432: ...uniqueHttpHooks, 1433: ...callbackHooks, 1434: ...functionHooks, 1435: ] 1436: const hasIfCondition = uniqueHooks.some( 1437: h => 1438: (h.hook.type === 'command' || 1439: h.hook.type === 'prompt' || 1440: h.hook.type === 'agent' || 1441: h.hook.type === 'http') && 1442: (h.hook as { if?: string }).if, 1443: ) 1444: const ifMatcher = hasIfCondition 1445: ? await prepareIfConditionMatcher(hookInput, tools) 1446: : undefined 1447: const ifFilteredHooks = uniqueHooks.filter(h => { 1448: if ( 1449: h.hook.type !== 'command' && 1450: h.hook.type !== 'prompt' && 1451: h.hook.type !== 'agent' && 1452: h.hook.type !== 'http' 1453: ) { 1454: return true 1455: } 1456: const ifCondition = (h.hook as { if?: string }).if 1457: if (!ifCondition) { 1458: return true 1459: } 1460: if (!ifMatcher) { 1461: logForDebugging( 1462: `Hook if condition "${ifCondition}" cannot be evaluated for non-tool event ${hookInput.hook_event_name}`, 1463: ) 1464: return false 1465: } 1466: if (ifMatcher(ifCondition)) { 1467: return true 1468: } 1469: logForDebugging( 1470: `Skipping hook due to if condition "${ifCondition}" not matching`, 1471: ) 1472: return false 1473: }) 1474: const filteredHooks = 1475: hookEvent === 'SessionStart' || hookEvent === 'Setup' 1476: ? ifFilteredHooks.filter(h => { 1477: if (h.hook.type === 'http') { 1478: logForDebugging( 1479: `Skipping HTTP hook ${(h.hook as { url: string }).url} — HTTP hooks are not supported for ${hookEvent}`, 1480: ) 1481: return false 1482: } 1483: return true 1484: }) 1485: : ifFilteredHooks 1486: logForDebugging( 1487: `Matched ${filteredHooks.length} unique hooks for query "${matchQuery || 'no match query'}" (${matchedHooks.length} before deduplication)`, 1488: { level: 'verbose' }, 1489: ) 1490: return filteredHooks 1491: } catch { 1492: return [] 1493: } 1494: } 1495: export function getPreToolHookBlockingMessage( 1496: hookName: string, 1497: blockingError: HookBlockingError, 1498: ): string { 1499: return `${hookName} hook error: ${blockingError.blockingError}` 1500: } 1501: export function getStopHookMessage(blockingError: HookBlockingError): string { 1502: return `Stop hook feedback:\n${blockingError.blockingError}` 1503: } 1504: export function getTeammateIdleHookMessage( 1505: blockingError: HookBlockingError, 1506: ): string { 1507: return `TeammateIdle hook feedback:\n${blockingError.blockingError}` 1508: } 1509: export function getTaskCreatedHookMessage( 1510: blockingError: HookBlockingError, 1511: ): string { 1512: return `TaskCreated hook feedback:\n${blockingError.blockingError}` 1513: } 1514: export function getTaskCompletedHookMessage( 1515: blockingError: HookBlockingError, 1516: ): string { 1517: return `TaskCompleted hook feedback:\n${blockingError.blockingError}` 1518: } 1519: export function getUserPromptSubmitHookBlockingMessage( 1520: blockingError: HookBlockingError, 1521: ): string { 1522: return `UserPromptSubmit operation blocked by hook:\n${blockingError.blockingError}` 1523: } 1524: async function* executeHooks({ 1525: hookInput, 1526: toolUseID, 1527: matchQuery, 1528: signal, 1529: timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 1530: toolUseContext, 1531: messages, 1532: forceSyncExecution, 1533: requestPrompt, 1534: toolInputSummary, 1535: }: { 1536: hookInput: HookInput 1537: toolUseID: string 1538: matchQuery?: string 1539: signal?: AbortSignal 1540: timeoutMs?: number 1541: toolUseContext?: ToolUseContext 1542: messages?: Message[] 1543: forceSyncExecution?: boolean 1544: requestPrompt?: ( 1545: sourceName: string, 1546: toolInputSummary?: string | null, 1547: ) => (request: PromptRequest) => Promise<PromptResponse> 1548: toolInputSummary?: string | null 1549: }): AsyncGenerator<AggregatedHookResult> { 1550: if (shouldDisableAllHooksIncludingManaged()) { 1551: return 1552: } 1553: if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 1554: return 1555: } 1556: const hookEvent = hookInput.hook_event_name 1557: const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent 1558: const boundRequestPrompt = requestPrompt?.(hookName, toolInputSummary) 1559: if (shouldSkipHookDueToTrust()) { 1560: logForDebugging( 1561: `Skipping ${hookName} hook execution - workspace trust not accepted`, 1562: ) 1563: return 1564: } 1565: const appState = toolUseContext ? toolUseContext.getAppState() : undefined 1566: const sessionId = toolUseContext?.agentId ?? getSessionId() 1567: const matchingHooks = await getMatchingHooks( 1568: appState, 1569: sessionId, 1570: hookEvent, 1571: hookInput, 1572: toolUseContext?.options?.tools, 1573: ) 1574: if (matchingHooks.length === 0) { 1575: return 1576: } 1577: if (signal?.aborted) { 1578: return 1579: } 1580: const userHooks = matchingHooks.filter(h => !isInternalHook(h)) 1581: if (userHooks.length > 0) { 1582: const pluginHookCounts = getPluginHookCounts(userHooks) 1583: const hookTypeCounts = getHookTypeCounts(userHooks) 1584: logEvent(`tengu_run_hook`, { 1585: hookName: 1586: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1587: numCommands: userHooks.length, 1588: hookTypeCounts: jsonStringify( 1589: hookTypeCounts, 1590: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1591: ...(pluginHookCounts && { 1592: pluginHookCounts: jsonStringify( 1593: pluginHookCounts, 1594: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1595: }), 1596: }) 1597: } else { 1598: const batchStartTime = Date.now() 1599: const context = toolUseContext 1600: ? { 1601: getAppState: toolUseContext.getAppState, 1602: updateAttributionState: toolUseContext.updateAttributionState, 1603: } 1604: : undefined 1605: for (const [i, { hook }] of matchingHooks.entries()) { 1606: if (hook.type === 'callback') { 1607: await hook.callback(hookInput, toolUseID, signal, i, context) 1608: } 1609: } 1610: const totalDurationMs = Date.now() - batchStartTime 1611: getStatsStore()?.observe('hook_duration_ms', totalDurationMs) 1612: addToTurnHookDuration(totalDurationMs) 1613: logEvent(`tengu_repl_hook_finished`, { 1614: hookName: 1615: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 1616: numCommands: matchingHooks.length, 1617: numSuccess: matchingHooks.length, 1618: numBlocking: 0, 1619: numNonBlockingError: 0, 1620: numCancelled: 0, 1621: totalDurationMs, 1622: }) 1623: return 1624: } 1625: const hookDefinitionsJson = isBetaTracingEnabled() 1626: ? jsonStringify(getHookDefinitionsForTelemetry(matchingHooks)) 1627: : '[]' 1628: if (isBetaTracingEnabled()) { 1629: void logOTelEvent('hook_execution_start', { 1630: hook_event: hookEvent, 1631: hook_name: hookName, 1632: num_hooks: String(matchingHooks.length), 1633: managed_only: String(shouldAllowManagedHooksOnly()), 1634: hook_definitions: hookDefinitionsJson, 1635: hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', 1636: }) 1637: } 1638: const hookSpan = startHookSpan( 1639: hookEvent, 1640: hookName, 1641: matchingHooks.length, 1642: hookDefinitionsJson, 1643: ) 1644: for (const { hook } of matchingHooks) { 1645: yield { 1646: message: { 1647: type: 'progress', 1648: data: { 1649: type: 'hook_progress', 1650: hookEvent, 1651: hookName, 1652: command: getHookDisplayText(hook), 1653: ...(hook.type === 'prompt' && { promptText: hook.prompt }), 1654: ...('statusMessage' in hook && 1655: hook.statusMessage != null && { 1656: statusMessage: hook.statusMessage, 1657: }), 1658: }, 1659: parentToolUseID: toolUseID, 1660: toolUseID, 1661: timestamp: new Date().toISOString(), 1662: uuid: randomUUID(), 1663: }, 1664: } 1665: } 1666: const batchStartTime = Date.now() 1667: let jsonInputResult: 1668: | { ok: true; value: string } 1669: | { ok: false; error: unknown } 1670: | undefined 1671: function getJsonInput() { 1672: if (jsonInputResult !== undefined) { 1673: return jsonInputResult 1674: } 1675: try { 1676: return (jsonInputResult = { ok: true, value: jsonStringify(hookInput) }) 1677: } catch (error) { 1678: logError( 1679: Error(`Failed to stringify hook ${hookName} input`, { cause: error }), 1680: ) 1681: return (jsonInputResult = { ok: false, error }) 1682: } 1683: } 1684: const hookPromises = matchingHooks.map(async function* ( 1685: { hook, pluginRoot, pluginId, skillRoot }, 1686: hookIndex, 1687: ): AsyncGenerator<HookResult> { 1688: if (hook.type === 'callback') { 1689: const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 1690: const { signal: abortSignal, cleanup } = createCombinedAbortSignal( 1691: signal, 1692: { timeoutMs: callbackTimeoutMs }, 1693: ) 1694: yield executeHookCallback({ 1695: toolUseID, 1696: hook, 1697: hookEvent, 1698: hookInput, 1699: signal: abortSignal, 1700: hookIndex, 1701: toolUseContext, 1702: }).finally(cleanup) 1703: return 1704: } 1705: if (hook.type === 'function') { 1706: if (!messages) { 1707: yield { 1708: message: createAttachmentMessage({ 1709: type: 'hook_error_during_execution', 1710: hookName, 1711: toolUseID, 1712: hookEvent, 1713: content: 'Messages not provided for function hook', 1714: }), 1715: outcome: 'non_blocking_error', 1716: hook, 1717: } 1718: return 1719: } 1720: yield executeFunctionHook({ 1721: hook, 1722: messages, 1723: hookName, 1724: toolUseID, 1725: hookEvent, 1726: timeoutMs, 1727: signal, 1728: }) 1729: return 1730: } 1731: const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 1732: const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { 1733: timeoutMs: commandTimeoutMs, 1734: }) 1735: const hookId = randomUUID() 1736: const hookStartMs = Date.now() 1737: const hookCommand = getHookDisplayText(hook) 1738: try { 1739: const jsonInputRes = getJsonInput() 1740: if (!jsonInputRes.ok) { 1741: yield { 1742: message: createAttachmentMessage({ 1743: type: 'hook_error_during_execution', 1744: hookName, 1745: toolUseID, 1746: hookEvent, 1747: content: `Failed to prepare hook input: ${errorMessage(jsonInputRes.error)}`, 1748: command: hookCommand, 1749: durationMs: Date.now() - hookStartMs, 1750: }), 1751: outcome: 'non_blocking_error', 1752: hook, 1753: } 1754: cleanup() 1755: return 1756: } 1757: const jsonInput = jsonInputRes.value 1758: if (hook.type === 'prompt') { 1759: if (!toolUseContext) { 1760: throw new Error( 1761: 'ToolUseContext is required for prompt hooks. This is a bug.', 1762: ) 1763: } 1764: const promptResult = await execPromptHook( 1765: hook, 1766: hookName, 1767: hookEvent, 1768: jsonInput, 1769: abortSignal, 1770: toolUseContext, 1771: messages, 1772: toolUseID, 1773: ) 1774: if (promptResult.message?.type === 'attachment') { 1775: const att = promptResult.message.attachment 1776: if ( 1777: att.type === 'hook_success' || 1778: att.type === 'hook_non_blocking_error' 1779: ) { 1780: att.command = hookCommand 1781: att.durationMs = Date.now() - hookStartMs 1782: } 1783: } 1784: yield promptResult 1785: cleanup?.() 1786: return 1787: } 1788: if (hook.type === 'agent') { 1789: if (!toolUseContext) { 1790: throw new Error( 1791: 'ToolUseContext is required for agent hooks. This is a bug.', 1792: ) 1793: } 1794: if (!messages) { 1795: throw new Error( 1796: 'Messages are required for agent hooks. This is a bug.', 1797: ) 1798: } 1799: const agentResult = await execAgentHook( 1800: hook, 1801: hookName, 1802: hookEvent, 1803: jsonInput, 1804: abortSignal, 1805: toolUseContext, 1806: toolUseID, 1807: messages, 1808: 'agent_type' in hookInput 1809: ? (hookInput.agent_type as string) 1810: : undefined, 1811: ) 1812: if (agentResult.message?.type === 'attachment') { 1813: const att = agentResult.message.attachment 1814: if ( 1815: att.type === 'hook_success' || 1816: att.type === 'hook_non_blocking_error' 1817: ) { 1818: att.command = hookCommand 1819: att.durationMs = Date.now() - hookStartMs 1820: } 1821: } 1822: yield agentResult 1823: cleanup?.() 1824: return 1825: } 1826: if (hook.type === 'http') { 1827: emitHookStarted(hookId, hookName, hookEvent) 1828: const httpResult = await execHttpHook( 1829: hook, 1830: hookEvent, 1831: jsonInput, 1832: signal, 1833: ) 1834: cleanup?.() 1835: if (httpResult.aborted) { 1836: emitHookResponse({ 1837: hookId, 1838: hookName, 1839: hookEvent, 1840: output: 'Hook cancelled', 1841: stdout: '', 1842: stderr: '', 1843: exitCode: undefined, 1844: outcome: 'cancelled', 1845: }) 1846: yield { 1847: message: createAttachmentMessage({ 1848: type: 'hook_cancelled', 1849: hookName, 1850: toolUseID, 1851: hookEvent, 1852: }), 1853: outcome: 'cancelled' as const, 1854: hook, 1855: } 1856: return 1857: } 1858: if (httpResult.error || !httpResult.ok) { 1859: const stderr = 1860: httpResult.error || `HTTP ${httpResult.statusCode} from ${hook.url}` 1861: emitHookResponse({ 1862: hookId, 1863: hookName, 1864: hookEvent, 1865: output: stderr, 1866: stdout: '', 1867: stderr, 1868: exitCode: httpResult.statusCode, 1869: outcome: 'error', 1870: }) 1871: yield { 1872: message: createAttachmentMessage({ 1873: type: 'hook_non_blocking_error', 1874: hookName, 1875: toolUseID, 1876: hookEvent, 1877: stderr, 1878: stdout: '', 1879: exitCode: httpResult.statusCode ?? 0, 1880: }), 1881: outcome: 'non_blocking_error' as const, 1882: hook, 1883: } 1884: return 1885: } 1886: const { json: httpJson, validationError: httpValidationError } = 1887: parseHttpHookOutput(httpResult.body) 1888: if (httpValidationError) { 1889: emitHookResponse({ 1890: hookId, 1891: hookName, 1892: hookEvent, 1893: output: httpResult.body, 1894: stdout: httpResult.body, 1895: stderr: `JSON validation failed: ${httpValidationError}`, 1896: exitCode: httpResult.statusCode, 1897: outcome: 'error', 1898: }) 1899: yield { 1900: message: createAttachmentMessage({ 1901: type: 'hook_non_blocking_error', 1902: hookName, 1903: toolUseID, 1904: hookEvent, 1905: stderr: `JSON validation failed: ${httpValidationError}`, 1906: stdout: httpResult.body, 1907: exitCode: httpResult.statusCode ?? 0, 1908: }), 1909: outcome: 'non_blocking_error' as const, 1910: hook, 1911: } 1912: return 1913: } 1914: if (httpJson && isAsyncHookJSONOutput(httpJson)) { 1915: emitHookResponse({ 1916: hookId, 1917: hookName, 1918: hookEvent, 1919: output: httpResult.body, 1920: stdout: httpResult.body, 1921: stderr: '', 1922: exitCode: httpResult.statusCode, 1923: outcome: 'success', 1924: }) 1925: yield { 1926: outcome: 'success' as const, 1927: hook, 1928: } 1929: return 1930: } 1931: if (httpJson) { 1932: const processed = processHookJSONOutput({ 1933: json: httpJson, 1934: command: hook.url, 1935: hookName, 1936: toolUseID, 1937: hookEvent, 1938: expectedHookEvent: hookEvent, 1939: stdout: httpResult.body, 1940: stderr: '', 1941: exitCode: httpResult.statusCode, 1942: }) 1943: emitHookResponse({ 1944: hookId, 1945: hookName, 1946: hookEvent, 1947: output: httpResult.body, 1948: stdout: httpResult.body, 1949: stderr: '', 1950: exitCode: httpResult.statusCode, 1951: outcome: 'success', 1952: }) 1953: yield { 1954: ...processed, 1955: outcome: 'success' as const, 1956: hook, 1957: } 1958: return 1959: } 1960: return 1961: } 1962: emitHookStarted(hookId, hookName, hookEvent) 1963: const result = await execCommandHook( 1964: hook, 1965: hookEvent, 1966: hookName, 1967: jsonInput, 1968: abortSignal, 1969: hookId, 1970: hookIndex, 1971: pluginRoot, 1972: pluginId, 1973: skillRoot, 1974: forceSyncExecution, 1975: boundRequestPrompt, 1976: ) 1977: cleanup?.() 1978: const durationMs = Date.now() - hookStartMs 1979: if (result.backgrounded) { 1980: yield { 1981: outcome: 'success' as const, 1982: hook, 1983: } 1984: return 1985: } 1986: if (result.aborted) { 1987: emitHookResponse({ 1988: hookId, 1989: hookName, 1990: hookEvent, 1991: output: result.output, 1992: stdout: result.stdout, 1993: stderr: result.stderr, 1994: exitCode: result.status, 1995: outcome: 'cancelled', 1996: }) 1997: yield { 1998: message: createAttachmentMessage({ 1999: type: 'hook_cancelled', 2000: hookName, 2001: toolUseID, 2002: hookEvent, 2003: command: hookCommand, 2004: durationMs, 2005: }), 2006: outcome: 'cancelled' as const, 2007: hook, 2008: } 2009: return 2010: } 2011: const { json, plainText, validationError } = parseHookOutput( 2012: result.stdout, 2013: ) 2014: if (validationError) { 2015: emitHookResponse({ 2016: hookId, 2017: hookName, 2018: hookEvent, 2019: output: result.output, 2020: stdout: result.stdout, 2021: stderr: `JSON validation failed: ${validationError}`, 2022: exitCode: 1, 2023: outcome: 'error', 2024: }) 2025: yield { 2026: message: createAttachmentMessage({ 2027: type: 'hook_non_blocking_error', 2028: hookName, 2029: toolUseID, 2030: hookEvent, 2031: stderr: `JSON validation failed: ${validationError}`, 2032: stdout: result.stdout, 2033: exitCode: 1, 2034: command: hookCommand, 2035: durationMs, 2036: }), 2037: outcome: 'non_blocking_error' as const, 2038: hook, 2039: } 2040: return 2041: } 2042: if (json) { 2043: if (isAsyncHookJSONOutput(json)) { 2044: yield { 2045: outcome: 'success' as const, 2046: hook, 2047: } 2048: return 2049: } 2050: const processed = processHookJSONOutput({ 2051: json, 2052: command: hookCommand, 2053: hookName, 2054: toolUseID, 2055: hookEvent, 2056: expectedHookEvent: hookEvent, 2057: stdout: result.stdout, 2058: stderr: result.stderr, 2059: exitCode: result.status, 2060: durationMs, 2061: }) 2062: if ( 2063: isSyncHookJSONOutput(json) && 2064: !json.suppressOutput && 2065: plainText && 2066: result.status === 0 2067: ) { 2068: const content = `${chalk.bold(hookName)} completed` 2069: emitHookResponse({ 2070: hookId, 2071: hookName, 2072: hookEvent, 2073: output: result.output, 2074: stdout: result.stdout, 2075: stderr: result.stderr, 2076: exitCode: result.status, 2077: outcome: 'success', 2078: }) 2079: yield { 2080: ...processed, 2081: message: 2082: processed.message || 2083: createAttachmentMessage({ 2084: type: 'hook_success', 2085: hookName, 2086: toolUseID, 2087: hookEvent, 2088: content, 2089: stdout: result.stdout, 2090: stderr: result.stderr, 2091: exitCode: result.status, 2092: command: hookCommand, 2093: durationMs, 2094: }), 2095: outcome: 'success' as const, 2096: hook, 2097: } 2098: return 2099: } 2100: emitHookResponse({ 2101: hookId, 2102: hookName, 2103: hookEvent, 2104: output: result.output, 2105: stdout: result.stdout, 2106: stderr: result.stderr, 2107: exitCode: result.status, 2108: outcome: result.status === 0 ? 'success' : 'error', 2109: }) 2110: yield { 2111: ...processed, 2112: outcome: 'success' as const, 2113: hook, 2114: } 2115: return 2116: } 2117: if (result.status === 0) { 2118: emitHookResponse({ 2119: hookId, 2120: hookName, 2121: hookEvent, 2122: output: result.output, 2123: stdout: result.stdout, 2124: stderr: result.stderr, 2125: exitCode: result.status, 2126: outcome: 'success', 2127: }) 2128: yield { 2129: message: createAttachmentMessage({ 2130: type: 'hook_success', 2131: hookName, 2132: toolUseID, 2133: hookEvent, 2134: content: result.stdout.trim(), 2135: stdout: result.stdout, 2136: stderr: result.stderr, 2137: exitCode: result.status, 2138: command: hookCommand, 2139: durationMs, 2140: }), 2141: outcome: 'success' as const, 2142: hook, 2143: } 2144: return 2145: } 2146: if (result.status === 2) { 2147: emitHookResponse({ 2148: hookId, 2149: hookName, 2150: hookEvent, 2151: output: result.output, 2152: stdout: result.stdout, 2153: stderr: result.stderr, 2154: exitCode: result.status, 2155: outcome: 'error', 2156: }) 2157: yield { 2158: blockingError: { 2159: blockingError: `[${hook.command}]: ${result.stderr || 'No stderr output'}`, 2160: command: hook.command, 2161: }, 2162: outcome: 'blocking' as const, 2163: hook, 2164: } 2165: return 2166: } 2167: emitHookResponse({ 2168: hookId, 2169: hookName, 2170: hookEvent, 2171: output: result.output, 2172: stdout: result.stdout, 2173: stderr: result.stderr, 2174: exitCode: result.status, 2175: outcome: 'error', 2176: }) 2177: yield { 2178: message: createAttachmentMessage({ 2179: type: 'hook_non_blocking_error', 2180: hookName, 2181: toolUseID, 2182: hookEvent, 2183: stderr: `Failed with non-blocking status code: ${result.stderr.trim() || 'No stderr output'}`, 2184: stdout: result.stdout, 2185: exitCode: result.status, 2186: command: hookCommand, 2187: durationMs, 2188: }), 2189: outcome: 'non_blocking_error' as const, 2190: hook, 2191: } 2192: return 2193: } catch (error) { 2194: cleanup?.() 2195: const errorMessage = 2196: error instanceof Error ? error.message : String(error) 2197: emitHookResponse({ 2198: hookId, 2199: hookName, 2200: hookEvent, 2201: output: `Failed to run: ${errorMessage}`, 2202: stdout: '', 2203: stderr: `Failed to run: ${errorMessage}`, 2204: exitCode: 1, 2205: outcome: 'error', 2206: }) 2207: yield { 2208: message: createAttachmentMessage({ 2209: type: 'hook_non_blocking_error', 2210: hookName, 2211: toolUseID, 2212: hookEvent, 2213: stderr: `Failed to run: ${errorMessage}`, 2214: stdout: '', 2215: exitCode: 1, 2216: command: hookCommand, 2217: durationMs: Date.now() - hookStartMs, 2218: }), 2219: outcome: 'non_blocking_error' as const, 2220: hook, 2221: } 2222: return 2223: } 2224: }) 2225: const outcomes = { 2226: success: 0, 2227: blocking: 0, 2228: non_blocking_error: 0, 2229: cancelled: 0, 2230: } 2231: let permissionBehavior: PermissionResult['behavior'] | undefined 2232: for await (const result of all(hookPromises)) { 2233: outcomes[result.outcome]++ 2234: if (result.preventContinuation) { 2235: logForDebugging( 2236: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) requested preventContinuation`, 2237: ) 2238: yield { 2239: preventContinuation: true, 2240: stopReason: result.stopReason, 2241: } 2242: } 2243: if (result.blockingError) { 2244: yield { 2245: blockingError: result.blockingError, 2246: } 2247: } 2248: if (result.message) { 2249: yield { message: result.message } 2250: } 2251: if (result.systemMessage) { 2252: yield { 2253: message: createAttachmentMessage({ 2254: type: 'hook_system_message', 2255: content: result.systemMessage, 2256: hookName, 2257: toolUseID, 2258: hookEvent, 2259: }), 2260: } 2261: } 2262: if (result.additionalContext) { 2263: logForDebugging( 2264: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided additionalContext (${result.additionalContext.length} chars)`, 2265: ) 2266: yield { 2267: additionalContexts: [result.additionalContext], 2268: } 2269: } 2270: if (result.initialUserMessage) { 2271: logForDebugging( 2272: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided initialUserMessage (${result.initialUserMessage.length} chars)`, 2273: ) 2274: yield { 2275: initialUserMessage: result.initialUserMessage, 2276: } 2277: } 2278: if (result.watchPaths && result.watchPaths.length > 0) { 2279: logForDebugging( 2280: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) provided ${result.watchPaths.length} watchPaths`, 2281: ) 2282: yield { 2283: watchPaths: result.watchPaths, 2284: } 2285: } 2286: if (result.updatedMCPToolOutput) { 2287: logForDebugging( 2288: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) replaced MCP tool output`, 2289: ) 2290: yield { 2291: updatedMCPToolOutput: result.updatedMCPToolOutput, 2292: } 2293: } 2294: if (result.permissionBehavior) { 2295: logForDebugging( 2296: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) returned permissionDecision: ${result.permissionBehavior}${result.hookPermissionDecisionReason ? ` (reason: ${result.hookPermissionDecisionReason})` : ''}`, 2297: ) 2298: switch (result.permissionBehavior) { 2299: case 'deny': 2300: permissionBehavior = 'deny' 2301: break 2302: case 'ask': 2303: if (permissionBehavior !== 'deny') { 2304: permissionBehavior = 'ask' 2305: } 2306: break 2307: case 'allow': 2308: if (!permissionBehavior) { 2309: permissionBehavior = 'allow' 2310: } 2311: break 2312: case 'passthrough': 2313: break 2314: } 2315: } 2316: if (permissionBehavior !== undefined) { 2317: const updatedInput = 2318: result.updatedInput && 2319: (result.permissionBehavior === 'allow' || 2320: result.permissionBehavior === 'ask') 2321: ? result.updatedInput 2322: : undefined 2323: if (updatedInput) { 2324: logForDebugging( 2325: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(updatedInput).join(', ')}]`, 2326: ) 2327: } 2328: yield { 2329: permissionBehavior, 2330: hookPermissionDecisionReason: result.hookPermissionDecisionReason, 2331: hookSource: matchingHooks.find(m => m.hook === result.hook)?.hookSource, 2332: updatedInput, 2333: } 2334: } 2335: if (result.updatedInput && result.permissionBehavior === undefined) { 2336: logForDebugging( 2337: `Hook ${hookEvent} (${getHookDisplayText(result.hook)}) modified tool input keys: [${Object.keys(result.updatedInput).join(', ')}]`, 2338: ) 2339: yield { 2340: updatedInput: result.updatedInput, 2341: } 2342: } 2343: if (result.permissionRequestResult) { 2344: yield { 2345: permissionRequestResult: result.permissionRequestResult, 2346: } 2347: } 2348: if (result.retry) { 2349: yield { 2350: retry: result.retry, 2351: } 2352: } 2353: if (result.elicitationResponse) { 2354: yield { 2355: elicitationResponse: result.elicitationResponse, 2356: } 2357: } 2358: if (result.elicitationResultResponse) { 2359: yield { 2360: elicitationResultResponse: result.elicitationResultResponse, 2361: } 2362: } 2363: if (appState && result.hook.type !== 'callback') { 2364: const sessionId = getSessionId() 2365: const matcher = matchQuery ?? '' 2366: const hookEntry = getSessionHookCallback( 2367: appState, 2368: sessionId, 2369: hookEvent, 2370: matcher, 2371: result.hook, 2372: ) 2373: // Invoke onHookSuccess only on success outcome 2374: if (hookEntry?.onHookSuccess && result.outcome === 'success') { 2375: try { 2376: hookEntry.onHookSuccess(result.hook, result as AggregatedHookResult) 2377: } catch (error) { 2378: logError( 2379: Error('Session hook success callback failed', { cause: error }), 2380: ) 2381: } 2382: } 2383: } 2384: } 2385: const totalDurationMs = Date.now() - batchStartTime 2386: getStatsStore()?.observe('hook_duration_ms', totalDurationMs) 2387: addToTurnHookDuration(totalDurationMs) 2388: logEvent(`tengu_repl_hook_finished`, { 2389: hookName: 2390: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2391: numCommands: matchingHooks.length, 2392: numSuccess: outcomes.success, 2393: numBlocking: outcomes.blocking, 2394: numNonBlockingError: outcomes.non_blocking_error, 2395: numCancelled: outcomes.cancelled, 2396: totalDurationMs, 2397: }) 2398: if (isBetaTracingEnabled()) { 2399: const hookDefinitionsComplete = 2400: getHookDefinitionsForTelemetry(matchingHooks) 2401: void logOTelEvent('hook_execution_complete', { 2402: hook_event: hookEvent, 2403: hook_name: hookName, 2404: num_hooks: String(matchingHooks.length), 2405: num_success: String(outcomes.success), 2406: num_blocking: String(outcomes.blocking), 2407: num_non_blocking_error: String(outcomes.non_blocking_error), 2408: num_cancelled: String(outcomes.cancelled), 2409: managed_only: String(shouldAllowManagedHooksOnly()), 2410: hook_definitions: jsonStringify(hookDefinitionsComplete), 2411: hook_source: shouldAllowManagedHooksOnly() ? 'policySettings' : 'merged', 2412: }) 2413: } 2414: endHookSpan(hookSpan, { 2415: numSuccess: outcomes.success, 2416: numBlocking: outcomes.blocking, 2417: numNonBlockingError: outcomes.non_blocking_error, 2418: numCancelled: outcomes.cancelled, 2419: }) 2420: } 2421: export type HookOutsideReplResult = { 2422: command: string 2423: succeeded: boolean 2424: output: string 2425: blocked: boolean 2426: watchPaths?: string[] 2427: systemMessage?: string 2428: } 2429: export function hasBlockingResult(results: HookOutsideReplResult[]): boolean { 2430: return results.some(r => r.blocked) 2431: } 2432: async function executeHooksOutsideREPL({ 2433: getAppState, 2434: hookInput, 2435: matchQuery, 2436: signal, 2437: timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2438: }: { 2439: getAppState?: () => AppState 2440: hookInput: HookInput 2441: matchQuery?: string 2442: signal?: AbortSignal 2443: timeoutMs: number 2444: }): Promise<HookOutsideReplResult[]> { 2445: if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { 2446: return [] 2447: } 2448: const hookEvent = hookInput.hook_event_name 2449: const hookName = matchQuery ? `${hookEvent}:${matchQuery}` : hookEvent 2450: if (shouldDisableAllHooksIncludingManaged()) { 2451: logForDebugging( 2452: `Skipping hooks for ${hookName} due to 'disableAllHooks' managed setting`, 2453: ) 2454: return [] 2455: } 2456: if (shouldSkipHookDueToTrust()) { 2457: logForDebugging( 2458: `Skipping ${hookName} hook execution - workspace trust not accepted`, 2459: ) 2460: return [] 2461: } 2462: const appState = getAppState ? getAppState() : undefined 2463: const sessionId = getSessionId() 2464: const matchingHooks = await getMatchingHooks( 2465: appState, 2466: sessionId, 2467: hookEvent, 2468: hookInput, 2469: ) 2470: if (matchingHooks.length === 0) { 2471: return [] 2472: } 2473: if (signal?.aborted) { 2474: return [] 2475: } 2476: const userHooks = matchingHooks.filter(h => !isInternalHook(h)) 2477: if (userHooks.length > 0) { 2478: const pluginHookCounts = getPluginHookCounts(userHooks) 2479: const hookTypeCounts = getHookTypeCounts(userHooks) 2480: logEvent(`tengu_run_hook`, { 2481: hookName: 2482: hookName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2483: numCommands: userHooks.length, 2484: hookTypeCounts: jsonStringify( 2485: hookTypeCounts, 2486: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2487: ...(pluginHookCounts && { 2488: pluginHookCounts: jsonStringify( 2489: pluginHookCounts, 2490: ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, 2491: }), 2492: }) 2493: } 2494: let jsonInput: string 2495: try { 2496: jsonInput = jsonStringify(hookInput) 2497: } catch (error) { 2498: logError(error) 2499: return [] 2500: } 2501: const hookPromises = matchingHooks.map( 2502: async ({ hook, pluginRoot, pluginId }, hookIndex) => { 2503: if (hook.type === 'callback') { 2504: const callbackTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 2505: const { signal: abortSignal, cleanup } = createCombinedAbortSignal( 2506: signal, 2507: { timeoutMs: callbackTimeoutMs }, 2508: ) 2509: try { 2510: const toolUseID = randomUUID() 2511: const json = await hook.callback( 2512: hookInput, 2513: toolUseID, 2514: abortSignal, 2515: hookIndex, 2516: ) 2517: cleanup?.() 2518: if (isAsyncHookJSONOutput(json)) { 2519: logForDebugging( 2520: `${hookName} [callback] returned async response, returning empty output`, 2521: ) 2522: return { 2523: command: 'callback', 2524: succeeded: true, 2525: output: '', 2526: blocked: false, 2527: } 2528: } 2529: const output = 2530: hookEvent === 'WorktreeCreate' && 2531: isSyncHookJSONOutput(json) && 2532: json.hookSpecificOutput?.hookEventName === 'WorktreeCreate' 2533: ? json.hookSpecificOutput.worktreePath 2534: : json.systemMessage || '' 2535: const blocked = 2536: isSyncHookJSONOutput(json) && json.decision === 'block' 2537: logForDebugging(`${hookName} [callback] completed successfully`) 2538: return { 2539: command: 'callback', 2540: succeeded: true, 2541: output, 2542: blocked, 2543: } 2544: } catch (error) { 2545: cleanup?.() 2546: const errorMessage = 2547: error instanceof Error ? error.message : String(error) 2548: logForDebugging( 2549: `${hookName} [callback] failed to run: ${errorMessage}`, 2550: { level: 'error' }, 2551: ) 2552: return { 2553: command: 'callback', 2554: succeeded: false, 2555: output: errorMessage, 2556: blocked: false, 2557: } 2558: } 2559: } 2560: if (hook.type === 'prompt') { 2561: return { 2562: command: hook.prompt, 2563: succeeded: false, 2564: output: 'Prompt stop hooks are not yet supported outside REPL', 2565: blocked: false, 2566: } 2567: } 2568: if (hook.type === 'agent') { 2569: return { 2570: command: hook.prompt, 2571: succeeded: false, 2572: output: 'Agent stop hooks are not yet supported outside REPL', 2573: blocked: false, 2574: } 2575: } 2576: if (hook.type === 'function') { 2577: logError( 2578: new Error( 2579: `Function hook reached executeHooksOutsideREPL for ${hookEvent}. Function hooks should only be used in REPL context (Stop hooks).`, 2580: ), 2581: ) 2582: return { 2583: command: 'function', 2584: succeeded: false, 2585: output: 'Internal error: function hook executed outside REPL context', 2586: blocked: false, 2587: } 2588: } 2589: if (hook.type === 'http') { 2590: try { 2591: const httpResult = await execHttpHook( 2592: hook, 2593: hookEvent, 2594: jsonInput, 2595: signal, 2596: ) 2597: if (httpResult.aborted) { 2598: logForDebugging(`${hookName} [${hook.url}] cancelled`) 2599: return { 2600: command: hook.url, 2601: succeeded: false, 2602: output: 'Hook cancelled', 2603: blocked: false, 2604: } 2605: } 2606: if (httpResult.error || !httpResult.ok) { 2607: const errMsg = 2608: httpResult.error || 2609: `HTTP ${httpResult.statusCode} from ${hook.url}` 2610: logForDebugging(`${hookName} [${hook.url}] failed: ${errMsg}`, { 2611: level: 'error', 2612: }) 2613: return { 2614: command: hook.url, 2615: succeeded: false, 2616: output: errMsg, 2617: blocked: false, 2618: } 2619: } 2620: const { json: httpJson, validationError: httpValidationError } = 2621: parseHttpHookOutput(httpResult.body) 2622: if (httpValidationError) { 2623: throw new Error(httpValidationError) 2624: } 2625: if (httpJson && !isAsyncHookJSONOutput(httpJson)) { 2626: logForDebugging( 2627: `Parsed JSON output from HTTP hook: ${jsonStringify(httpJson)}`, 2628: { level: 'verbose' }, 2629: ) 2630: } 2631: const jsonBlocked = 2632: httpJson && 2633: !isAsyncHookJSONOutput(httpJson) && 2634: isSyncHookJSONOutput(httpJson) && 2635: httpJson.decision === 'block' 2636: const output = 2637: hookEvent === 'WorktreeCreate' 2638: ? httpJson && 2639: isSyncHookJSONOutput(httpJson) && 2640: httpJson.hookSpecificOutput?.hookEventName === 'WorktreeCreate' 2641: ? httpJson.hookSpecificOutput.worktreePath 2642: : '' 2643: : httpResult.body 2644: return { 2645: command: hook.url, 2646: succeeded: true, 2647: output, 2648: blocked: !!jsonBlocked, 2649: } 2650: } catch (error) { 2651: const errorMessage = 2652: error instanceof Error ? error.message : String(error) 2653: logForDebugging( 2654: `${hookName} [${hook.url}] failed to run: ${errorMessage}`, 2655: { level: 'error' }, 2656: ) 2657: return { 2658: command: hook.url, 2659: succeeded: false, 2660: output: errorMessage, 2661: blocked: false, 2662: } 2663: } 2664: } 2665: const commandTimeoutMs = hook.timeout ? hook.timeout * 1000 : timeoutMs 2666: const { signal: abortSignal, cleanup } = createCombinedAbortSignal( 2667: signal, 2668: { timeoutMs: commandTimeoutMs }, 2669: ) 2670: try { 2671: const result = await execCommandHook( 2672: hook, 2673: hookEvent, 2674: hookName, 2675: jsonInput, 2676: abortSignal, 2677: randomUUID(), 2678: hookIndex, 2679: pluginRoot, 2680: pluginId, 2681: ) 2682: cleanup?.() 2683: if (result.aborted) { 2684: logForDebugging(`${hookName} [${hook.command}] cancelled`) 2685: return { 2686: command: hook.command, 2687: succeeded: false, 2688: output: 'Hook cancelled', 2689: blocked: false, 2690: } 2691: } 2692: logForDebugging( 2693: `${hookName} [${hook.command}] completed with status ${result.status}`, 2694: ) 2695: const { json, validationError } = parseHookOutput(result.stdout) 2696: if (validationError) { 2697: throw new Error(validationError) 2698: } 2699: if (json && !isAsyncHookJSONOutput(json)) { 2700: logForDebugging( 2701: `Parsed JSON output from hook: ${jsonStringify(json)}`, 2702: { level: 'verbose' }, 2703: ) 2704: } 2705: const jsonBlocked = 2706: json && 2707: !isAsyncHookJSONOutput(json) && 2708: isSyncHookJSONOutput(json) && 2709: json.decision === 'block' 2710: const blocked = result.status === 2 || !!jsonBlocked 2711: const output = 2712: result.status === 0 ? result.stdout || '' : result.stderr || '' 2713: const watchPaths = 2714: json && 2715: isSyncHookJSONOutput(json) && 2716: json.hookSpecificOutput && 2717: 'watchPaths' in json.hookSpecificOutput 2718: ? json.hookSpecificOutput.watchPaths 2719: : undefined 2720: const systemMessage = 2721: json && isSyncHookJSONOutput(json) ? json.systemMessage : undefined 2722: return { 2723: command: hook.command, 2724: succeeded: result.status === 0, 2725: output, 2726: blocked, 2727: watchPaths, 2728: systemMessage, 2729: } 2730: } catch (error) { 2731: cleanup?.() 2732: const errorMessage = 2733: error instanceof Error ? error.message : String(error) 2734: logForDebugging( 2735: `${hookName} [${hook.command}] failed to run: ${errorMessage}`, 2736: { level: 'error' }, 2737: ) 2738: return { 2739: command: hook.command, 2740: succeeded: false, 2741: output: errorMessage, 2742: blocked: false, 2743: } 2744: } 2745: }, 2746: ) 2747: return await Promise.all(hookPromises) 2748: } 2749: export async function* executePreToolHooks<ToolInput>( 2750: toolName: string, 2751: toolUseID: string, 2752: toolInput: ToolInput, 2753: toolUseContext: ToolUseContext, 2754: permissionMode?: string, 2755: signal?: AbortSignal, 2756: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2757: requestPrompt?: ( 2758: sourceName: string, 2759: toolInputSummary?: string | null, 2760: ) => (request: PromptRequest) => Promise<PromptResponse>, 2761: toolInputSummary?: string | null, 2762: ): AsyncGenerator<AggregatedHookResult> { 2763: const appState = toolUseContext.getAppState() 2764: const sessionId = toolUseContext.agentId ?? getSessionId() 2765: if (!hasHookForEvent('PreToolUse', appState, sessionId)) { 2766: return 2767: } 2768: logForDebugging(`executePreToolHooks called for tool: ${toolName}`, { 2769: level: 'verbose', 2770: }) 2771: const hookInput: PreToolUseHookInput = { 2772: ...createBaseHookInput(permissionMode, undefined, toolUseContext), 2773: hook_event_name: 'PreToolUse', 2774: tool_name: toolName, 2775: tool_input: toolInput, 2776: tool_use_id: toolUseID, 2777: } 2778: yield* executeHooks({ 2779: hookInput, 2780: toolUseID, 2781: matchQuery: toolName, 2782: signal, 2783: timeoutMs, 2784: toolUseContext, 2785: requestPrompt, 2786: toolInputSummary, 2787: }) 2788: } 2789: export async function* executePostToolHooks<ToolInput, ToolResponse>( 2790: toolName: string, 2791: toolUseID: string, 2792: toolInput: ToolInput, 2793: toolResponse: ToolResponse, 2794: toolUseContext: ToolUseContext, 2795: permissionMode?: string, 2796: signal?: AbortSignal, 2797: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2798: ): AsyncGenerator<AggregatedHookResult> { 2799: const hookInput: PostToolUseHookInput = { 2800: ...createBaseHookInput(permissionMode, undefined, toolUseContext), 2801: hook_event_name: 'PostToolUse', 2802: tool_name: toolName, 2803: tool_input: toolInput, 2804: tool_response: toolResponse, 2805: tool_use_id: toolUseID, 2806: } 2807: yield* executeHooks({ 2808: hookInput, 2809: toolUseID, 2810: matchQuery: toolName, 2811: signal, 2812: timeoutMs, 2813: toolUseContext, 2814: }) 2815: } 2816: export async function* executePostToolUseFailureHooks<ToolInput>( 2817: toolName: string, 2818: toolUseID: string, 2819: toolInput: ToolInput, 2820: error: string, 2821: toolUseContext: ToolUseContext, 2822: isInterrupt?: boolean, 2823: permissionMode?: string, 2824: signal?: AbortSignal, 2825: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2826: ): AsyncGenerator<AggregatedHookResult> { 2827: const appState = toolUseContext.getAppState() 2828: const sessionId = toolUseContext.agentId ?? getSessionId() 2829: if (!hasHookForEvent('PostToolUseFailure', appState, sessionId)) { 2830: return 2831: } 2832: const hookInput: PostToolUseFailureHookInput = { 2833: ...createBaseHookInput(permissionMode, undefined, toolUseContext), 2834: hook_event_name: 'PostToolUseFailure', 2835: tool_name: toolName, 2836: tool_input: toolInput, 2837: tool_use_id: toolUseID, 2838: error, 2839: is_interrupt: isInterrupt, 2840: } 2841: yield* executeHooks({ 2842: hookInput, 2843: toolUseID, 2844: matchQuery: toolName, 2845: signal, 2846: timeoutMs, 2847: toolUseContext, 2848: }) 2849: } 2850: export async function* executePermissionDeniedHooks<ToolInput>( 2851: toolName: string, 2852: toolUseID: string, 2853: toolInput: ToolInput, 2854: reason: string, 2855: toolUseContext: ToolUseContext, 2856: permissionMode?: string, 2857: signal?: AbortSignal, 2858: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2859: ): AsyncGenerator<AggregatedHookResult> { 2860: const appState = toolUseContext.getAppState() 2861: const sessionId = toolUseContext.agentId ?? getSessionId() 2862: if (!hasHookForEvent('PermissionDenied', appState, sessionId)) { 2863: return 2864: } 2865: const hookInput: PermissionDeniedHookInput = { 2866: ...createBaseHookInput(permissionMode, undefined, toolUseContext), 2867: hook_event_name: 'PermissionDenied', 2868: tool_name: toolName, 2869: tool_input: toolInput, 2870: tool_use_id: toolUseID, 2871: reason, 2872: } 2873: yield* executeHooks({ 2874: hookInput, 2875: toolUseID, 2876: matchQuery: toolName, 2877: signal, 2878: timeoutMs, 2879: toolUseContext, 2880: }) 2881: } 2882: export async function executeNotificationHooks( 2883: notificationData: { 2884: message: string 2885: title?: string 2886: notificationType: string 2887: }, 2888: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2889: ): Promise<void> { 2890: const { message, title, notificationType } = notificationData 2891: const hookInput: NotificationHookInput = { 2892: ...createBaseHookInput(undefined), 2893: hook_event_name: 'Notification', 2894: message, 2895: title, 2896: notification_type: notificationType, 2897: } 2898: await executeHooksOutsideREPL({ 2899: hookInput, 2900: timeoutMs, 2901: matchQuery: notificationType, 2902: }) 2903: } 2904: export async function executeStopFailureHooks( 2905: lastMessage: AssistantMessage, 2906: toolUseContext?: ToolUseContext, 2907: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2908: ): Promise<void> { 2909: const appState = toolUseContext?.getAppState() 2910: const sessionId = getSessionId() 2911: if (!hasHookForEvent('StopFailure', appState, sessionId)) return 2912: const lastAssistantText = 2913: extractTextContent(lastMessage.message.content, '\n').trim() || undefined 2914: const error = lastMessage.error ?? 'unknown' 2915: const hookInput: StopFailureHookInput = { 2916: ...createBaseHookInput(undefined, undefined, toolUseContext), 2917: hook_event_name: 'StopFailure', 2918: error, 2919: error_details: lastMessage.errorDetails, 2920: last_assistant_message: lastAssistantText, 2921: } 2922: await executeHooksOutsideREPL({ 2923: getAppState: toolUseContext?.getAppState, 2924: hookInput, 2925: timeoutMs, 2926: matchQuery: error, 2927: }) 2928: } 2929: export async function* executeStopHooks( 2930: permissionMode?: string, 2931: signal?: AbortSignal, 2932: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2933: stopHookActive: boolean = false, 2934: subagentId?: AgentId, 2935: toolUseContext?: ToolUseContext, 2936: messages?: Message[], 2937: agentType?: string, 2938: requestPrompt?: ( 2939: sourceName: string, 2940: toolInputSummary?: string | null, 2941: ) => (request: PromptRequest) => Promise<PromptResponse>, 2942: ): AsyncGenerator<AggregatedHookResult> { 2943: const hookEvent = subagentId ? 'SubagentStop' : 'Stop' 2944: const appState = toolUseContext?.getAppState() 2945: const sessionId = toolUseContext?.agentId ?? getSessionId() 2946: if (!hasHookForEvent(hookEvent, appState, sessionId)) { 2947: return 2948: } 2949: const lastAssistantMessage = messages 2950: ? getLastAssistantMessage(messages) 2951: : undefined 2952: const lastAssistantText = lastAssistantMessage 2953: ? extractTextContent(lastAssistantMessage.message.content, '\n').trim() || 2954: undefined 2955: : undefined 2956: const hookInput: StopHookInput | SubagentStopHookInput = subagentId 2957: ? { 2958: ...createBaseHookInput(permissionMode), 2959: hook_event_name: 'SubagentStop', 2960: stop_hook_active: stopHookActive, 2961: agent_id: subagentId, 2962: agent_transcript_path: getAgentTranscriptPath(subagentId), 2963: agent_type: agentType ?? '', 2964: last_assistant_message: lastAssistantText, 2965: } 2966: : { 2967: ...createBaseHookInput(permissionMode), 2968: hook_event_name: 'Stop', 2969: stop_hook_active: stopHookActive, 2970: last_assistant_message: lastAssistantText, 2971: } 2972: yield* executeHooks({ 2973: hookInput, 2974: toolUseID: randomUUID(), 2975: signal, 2976: timeoutMs, 2977: toolUseContext, 2978: messages, 2979: requestPrompt, 2980: }) 2981: } 2982: export async function* executeTeammateIdleHooks( 2983: teammateName: string, 2984: teamName: string, 2985: permissionMode?: string, 2986: signal?: AbortSignal, 2987: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 2988: ): AsyncGenerator<AggregatedHookResult> { 2989: const hookInput: TeammateIdleHookInput = { 2990: ...createBaseHookInput(permissionMode), 2991: hook_event_name: 'TeammateIdle', 2992: teammate_name: teammateName, 2993: team_name: teamName, 2994: } 2995: yield* executeHooks({ 2996: hookInput, 2997: toolUseID: randomUUID(), 2998: signal, 2999: timeoutMs, 3000: }) 3001: } 3002: export async function* executeTaskCreatedHooks( 3003: taskId: string, 3004: taskSubject: string, 3005: taskDescription?: string, 3006: teammateName?: string, 3007: teamName?: string, 3008: permissionMode?: string, 3009: signal?: AbortSignal, 3010: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3011: toolUseContext?: ToolUseContext, 3012: ): AsyncGenerator<AggregatedHookResult> { 3013: const hookInput: TaskCreatedHookInput = { 3014: ...createBaseHookInput(permissionMode), 3015: hook_event_name: 'TaskCreated', 3016: task_id: taskId, 3017: task_subject: taskSubject, 3018: task_description: taskDescription, 3019: teammate_name: teammateName, 3020: team_name: teamName, 3021: } 3022: yield* executeHooks({ 3023: hookInput, 3024: toolUseID: randomUUID(), 3025: signal, 3026: timeoutMs, 3027: toolUseContext, 3028: }) 3029: } 3030: export async function* executeTaskCompletedHooks( 3031: taskId: string, 3032: taskSubject: string, 3033: taskDescription?: string, 3034: teammateName?: string, 3035: teamName?: string, 3036: permissionMode?: string, 3037: signal?: AbortSignal, 3038: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3039: toolUseContext?: ToolUseContext, 3040: ): AsyncGenerator<AggregatedHookResult> { 3041: const hookInput: TaskCompletedHookInput = { 3042: ...createBaseHookInput(permissionMode), 3043: hook_event_name: 'TaskCompleted', 3044: task_id: taskId, 3045: task_subject: taskSubject, 3046: task_description: taskDescription, 3047: teammate_name: teammateName, 3048: team_name: teamName, 3049: } 3050: yield* executeHooks({ 3051: hookInput, 3052: toolUseID: randomUUID(), 3053: signal, 3054: timeoutMs, 3055: toolUseContext, 3056: }) 3057: } 3058: export async function* executeUserPromptSubmitHooks( 3059: prompt: string, 3060: permissionMode: string, 3061: toolUseContext: ToolUseContext, 3062: requestPrompt?: ( 3063: sourceName: string, 3064: toolInputSummary?: string | null, 3065: ) => (request: PromptRequest) => Promise<PromptResponse>, 3066: ): AsyncGenerator<AggregatedHookResult> { 3067: const appState = toolUseContext.getAppState() 3068: const sessionId = toolUseContext.agentId ?? getSessionId() 3069: if (!hasHookForEvent('UserPromptSubmit', appState, sessionId)) { 3070: return 3071: } 3072: const hookInput: UserPromptSubmitHookInput = { 3073: ...createBaseHookInput(permissionMode), 3074: hook_event_name: 'UserPromptSubmit', 3075: prompt, 3076: } 3077: yield* executeHooks({ 3078: hookInput, 3079: toolUseID: randomUUID(), 3080: signal: toolUseContext.abortController.signal, 3081: timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3082: toolUseContext, 3083: requestPrompt, 3084: }) 3085: } 3086: export async function* executeSessionStartHooks( 3087: source: 'startup' | 'resume' | 'clear' | 'compact', 3088: sessionId?: string, 3089: agentType?: string, 3090: model?: string, 3091: signal?: AbortSignal, 3092: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3093: forceSyncExecution?: boolean, 3094: ): AsyncGenerator<AggregatedHookResult> { 3095: const hookInput: SessionStartHookInput = { 3096: ...createBaseHookInput(undefined, sessionId), 3097: hook_event_name: 'SessionStart', 3098: source, 3099: agent_type: agentType, 3100: model, 3101: } 3102: yield* executeHooks({ 3103: hookInput, 3104: toolUseID: randomUUID(), 3105: matchQuery: source, 3106: signal, 3107: timeoutMs, 3108: forceSyncExecution, 3109: }) 3110: } 3111: export async function* executeSetupHooks( 3112: trigger: 'init' | 'maintenance', 3113: signal?: AbortSignal, 3114: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3115: forceSyncExecution?: boolean, 3116: ): AsyncGenerator<AggregatedHookResult> { 3117: const hookInput: SetupHookInput = { 3118: ...createBaseHookInput(undefined), 3119: hook_event_name: 'Setup', 3120: trigger, 3121: } 3122: yield* executeHooks({ 3123: hookInput, 3124: toolUseID: randomUUID(), 3125: matchQuery: trigger, 3126: signal, 3127: timeoutMs, 3128: forceSyncExecution, 3129: }) 3130: } 3131: export async function* executeSubagentStartHooks( 3132: agentId: string, 3133: agentType: string, 3134: signal?: AbortSignal, 3135: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3136: ): AsyncGenerator<AggregatedHookResult> { 3137: const hookInput: SubagentStartHookInput = { 3138: ...createBaseHookInput(undefined), 3139: hook_event_name: 'SubagentStart', 3140: agent_id: agentId, 3141: agent_type: agentType, 3142: } 3143: yield* executeHooks({ 3144: hookInput, 3145: toolUseID: randomUUID(), 3146: matchQuery: agentType, 3147: signal, 3148: timeoutMs, 3149: }) 3150: } 3151: export async function executePreCompactHooks( 3152: compactData: { 3153: trigger: 'manual' | 'auto' 3154: customInstructions: string | null 3155: }, 3156: signal?: AbortSignal, 3157: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3158: ): Promise<{ 3159: newCustomInstructions?: string 3160: userDisplayMessage?: string 3161: }> { 3162: const hookInput: PreCompactHookInput = { 3163: ...createBaseHookInput(undefined), 3164: hook_event_name: 'PreCompact', 3165: trigger: compactData.trigger, 3166: custom_instructions: compactData.customInstructions, 3167: } 3168: const results = await executeHooksOutsideREPL({ 3169: hookInput, 3170: matchQuery: compactData.trigger, 3171: signal, 3172: timeoutMs, 3173: }) 3174: if (results.length === 0) { 3175: return {} 3176: } 3177: const successfulOutputs = results 3178: .filter(result => result.succeeded && result.output.trim().length > 0) 3179: .map(result => result.output.trim()) 3180: const displayMessages: string[] = [] 3181: for (const result of results) { 3182: if (result.succeeded) { 3183: if (result.output.trim()) { 3184: displayMessages.push( 3185: `PreCompact [${result.command}] completed successfully: ${result.output.trim()}`, 3186: ) 3187: } else { 3188: displayMessages.push( 3189: `PreCompact [${result.command}] completed successfully`, 3190: ) 3191: } 3192: } else { 3193: if (result.output.trim()) { 3194: displayMessages.push( 3195: `PreCompact [${result.command}] failed: ${result.output.trim()}`, 3196: ) 3197: } else { 3198: displayMessages.push(`PreCompact [${result.command}] failed`) 3199: } 3200: } 3201: } 3202: return { 3203: newCustomInstructions: 3204: successfulOutputs.length > 0 ? successfulOutputs.join('\n\n') : undefined, 3205: userDisplayMessage: 3206: displayMessages.length > 0 ? displayMessages.join('\n') : undefined, 3207: } 3208: } 3209: export async function executePostCompactHooks( 3210: compactData: { 3211: trigger: 'manual' | 'auto' 3212: compactSummary: string 3213: }, 3214: signal?: AbortSignal, 3215: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3216: ): Promise<{ 3217: userDisplayMessage?: string 3218: }> { 3219: const hookInput: PostCompactHookInput = { 3220: ...createBaseHookInput(undefined), 3221: hook_event_name: 'PostCompact', 3222: trigger: compactData.trigger, 3223: compact_summary: compactData.compactSummary, 3224: } 3225: const results = await executeHooksOutsideREPL({ 3226: hookInput, 3227: matchQuery: compactData.trigger, 3228: signal, 3229: timeoutMs, 3230: }) 3231: if (results.length === 0) { 3232: return {} 3233: } 3234: const displayMessages: string[] = [] 3235: for (const result of results) { 3236: if (result.succeeded) { 3237: if (result.output.trim()) { 3238: displayMessages.push( 3239: `PostCompact [${result.command}] completed successfully: ${result.output.trim()}`, 3240: ) 3241: } else { 3242: displayMessages.push( 3243: `PostCompact [${result.command}] completed successfully`, 3244: ) 3245: } 3246: } else { 3247: if (result.output.trim()) { 3248: displayMessages.push( 3249: `PostCompact [${result.command}] failed: ${result.output.trim()}`, 3250: ) 3251: } else { 3252: displayMessages.push(`PostCompact [${result.command}] failed`) 3253: } 3254: } 3255: } 3256: return { 3257: userDisplayMessage: 3258: displayMessages.length > 0 ? displayMessages.join('\n') : undefined, 3259: } 3260: } 3261: export async function executeSessionEndHooks( 3262: reason: ExitReason, 3263: options?: { 3264: getAppState?: () => AppState 3265: setAppState?: (updater: (prev: AppState) => AppState) => void 3266: signal?: AbortSignal 3267: timeoutMs?: number 3268: }, 3269: ): Promise<void> { 3270: const { 3271: getAppState, 3272: setAppState, 3273: signal, 3274: timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3275: } = options || {} 3276: const hookInput: SessionEndHookInput = { 3277: ...createBaseHookInput(undefined), 3278: hook_event_name: 'SessionEnd', 3279: reason, 3280: } 3281: const results = await executeHooksOutsideREPL({ 3282: getAppState, 3283: hookInput, 3284: matchQuery: reason, 3285: signal, 3286: timeoutMs, 3287: }) 3288: for (const result of results) { 3289: if (!result.succeeded && result.output) { 3290: process.stderr.write( 3291: `SessionEnd hook [${result.command}] failed: ${result.output}\n`, 3292: ) 3293: } 3294: } 3295: if (setAppState) { 3296: const sessionId = getSessionId() 3297: clearSessionHooks(setAppState, sessionId) 3298: } 3299: } 3300: export async function* executePermissionRequestHooks<ToolInput>( 3301: toolName: string, 3302: toolUseID: string, 3303: toolInput: ToolInput, 3304: toolUseContext: ToolUseContext, 3305: permissionMode?: string, 3306: permissionSuggestions?: PermissionUpdate[], 3307: signal?: AbortSignal, 3308: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3309: requestPrompt?: ( 3310: sourceName: string, 3311: toolInputSummary?: string | null, 3312: ) => (request: PromptRequest) => Promise<PromptResponse>, 3313: toolInputSummary?: string | null, 3314: ): AsyncGenerator<AggregatedHookResult> { 3315: logForDebugging(`executePermissionRequestHooks called for tool: ${toolName}`) 3316: const hookInput: PermissionRequestHookInput = { 3317: ...createBaseHookInput(permissionMode, undefined, toolUseContext), 3318: hook_event_name: 'PermissionRequest', 3319: tool_name: toolName, 3320: tool_input: toolInput, 3321: permission_suggestions: permissionSuggestions, 3322: } 3323: yield* executeHooks({ 3324: hookInput, 3325: toolUseID, 3326: matchQuery: toolName, 3327: signal, 3328: timeoutMs, 3329: toolUseContext, 3330: requestPrompt, 3331: toolInputSummary, 3332: }) 3333: } 3334: export type ConfigChangeSource = 3335: | 'user_settings' 3336: | 'project_settings' 3337: | 'local_settings' 3338: | 'policy_settings' 3339: | 'skills' 3340: export async function executeConfigChangeHooks( 3341: source: ConfigChangeSource, 3342: filePath?: string, 3343: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3344: ): Promise<HookOutsideReplResult[]> { 3345: const hookInput: ConfigChangeHookInput = { 3346: ...createBaseHookInput(undefined), 3347: hook_event_name: 'ConfigChange', 3348: source, 3349: file_path: filePath, 3350: } 3351: const results = await executeHooksOutsideREPL({ 3352: hookInput, 3353: timeoutMs, 3354: matchQuery: source, 3355: }) 3356: if (source === 'policy_settings') { 3357: return results.map(r => ({ ...r, blocked: false })) 3358: } 3359: return results 3360: } 3361: async function executeEnvHooks( 3362: hookInput: HookInput, 3363: timeoutMs: number, 3364: ): Promise<{ 3365: results: HookOutsideReplResult[] 3366: watchPaths: string[] 3367: systemMessages: string[] 3368: }> { 3369: const results = await executeHooksOutsideREPL({ hookInput, timeoutMs }) 3370: if (results.length > 0) { 3371: invalidateSessionEnvCache() 3372: } 3373: const watchPaths = results.flatMap(r => r.watchPaths ?? []) 3374: const systemMessages = results 3375: .map(r => r.systemMessage) 3376: .filter((m): m is string => !!m) 3377: return { results, watchPaths, systemMessages } 3378: } 3379: export function executeCwdChangedHooks( 3380: oldCwd: string, 3381: newCwd: string, 3382: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3383: ): Promise<{ 3384: results: HookOutsideReplResult[] 3385: watchPaths: string[] 3386: systemMessages: string[] 3387: }> { 3388: const hookInput: CwdChangedHookInput = { 3389: ...createBaseHookInput(undefined), 3390: hook_event_name: 'CwdChanged', 3391: old_cwd: oldCwd, 3392: new_cwd: newCwd, 3393: } 3394: return executeEnvHooks(hookInput, timeoutMs) 3395: } 3396: export function executeFileChangedHooks( 3397: filePath: string, 3398: event: 'change' | 'add' | 'unlink', 3399: timeoutMs: number = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3400: ): Promise<{ 3401: results: HookOutsideReplResult[] 3402: watchPaths: string[] 3403: systemMessages: string[] 3404: }> { 3405: const hookInput: FileChangedHookInput = { 3406: ...createBaseHookInput(undefined), 3407: hook_event_name: 'FileChanged', 3408: file_path: filePath, 3409: event, 3410: } 3411: return executeEnvHooks(hookInput, timeoutMs) 3412: } 3413: export type InstructionsLoadReason = 3414: | 'session_start' 3415: | 'nested_traversal' 3416: | 'path_glob_match' 3417: | 'include' 3418: | 'compact' 3419: export type InstructionsMemoryType = 'User' | 'Project' | 'Local' | 'Managed' 3420: export function hasInstructionsLoadedHook(): boolean { 3421: const snapshotHooks = getHooksConfigFromSnapshot()?.['InstructionsLoaded'] 3422: if (snapshotHooks && snapshotHooks.length > 0) return true 3423: const registeredHooks = getRegisteredHooks()?.['InstructionsLoaded'] 3424: if (registeredHooks && registeredHooks.length > 0) return true 3425: return false 3426: } 3427: export async function executeInstructionsLoadedHooks( 3428: filePath: string, 3429: memoryType: InstructionsMemoryType, 3430: loadReason: InstructionsLoadReason, 3431: options?: { 3432: globs?: string[] 3433: triggerFilePath?: string 3434: parentFilePath?: string 3435: timeoutMs?: number 3436: }, 3437: ): Promise<void> { 3438: const { 3439: globs, 3440: triggerFilePath, 3441: parentFilePath, 3442: timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3443: } = options ?? {} 3444: const hookInput: InstructionsLoadedHookInput = { 3445: ...createBaseHookInput(undefined), 3446: hook_event_name: 'InstructionsLoaded', 3447: file_path: filePath, 3448: memory_type: memoryType, 3449: load_reason: loadReason, 3450: globs, 3451: trigger_file_path: triggerFilePath, 3452: parent_file_path: parentFilePath, 3453: } 3454: await executeHooksOutsideREPL({ 3455: hookInput, 3456: timeoutMs, 3457: matchQuery: loadReason, 3458: }) 3459: } 3460: export type ElicitationHookResult = { 3461: elicitationResponse?: ElicitationResponse 3462: blockingError?: HookBlockingError 3463: } 3464: export type ElicitationResultHookResult = { 3465: elicitationResultResponse?: ElicitationResponse 3466: blockingError?: HookBlockingError 3467: } 3468: function parseElicitationHookOutput( 3469: result: HookOutsideReplResult, 3470: expectedEventName: 'Elicitation' | 'ElicitationResult', 3471: ): { 3472: response?: ElicitationResponse 3473: blockingError?: HookBlockingError 3474: } { 3475: if (result.blocked && !result.succeeded) { 3476: return { 3477: blockingError: { 3478: blockingError: result.output || `Elicitation blocked by hook`, 3479: command: result.command, 3480: }, 3481: } 3482: } 3483: if (!result.output.trim()) { 3484: return {} 3485: } 3486: const trimmed = result.output.trim() 3487: if (!trimmed.startsWith('{')) { 3488: return {} 3489: } 3490: try { 3491: const parsed = hookJSONOutputSchema().parse(JSON.parse(trimmed)) 3492: if (isAsyncHookJSONOutput(parsed)) { 3493: return {} 3494: } 3495: if (!isSyncHookJSONOutput(parsed)) { 3496: return {} 3497: } 3498: if (parsed.decision === 'block' || result.blocked) { 3499: return { 3500: blockingError: { 3501: blockingError: parsed.reason || 'Elicitation blocked by hook', 3502: command: result.command, 3503: }, 3504: } 3505: } 3506: const specific = parsed.hookSpecificOutput 3507: if (!specific || specific.hookEventName !== expectedEventName) { 3508: return {} 3509: } 3510: if (!specific.action) { 3511: return {} 3512: } 3513: const response: ElicitationResponse = { 3514: action: specific.action, 3515: content: specific.content as ElicitationResponse['content'] | undefined, 3516: } 3517: const out: { 3518: response?: ElicitationResponse 3519: blockingError?: HookBlockingError 3520: } = { response } 3521: if (specific.action === 'decline') { 3522: out.blockingError = { 3523: blockingError: 3524: parsed.reason || 3525: (expectedEventName === 'Elicitation' 3526: ? 'Elicitation denied by hook' 3527: : 'Elicitation result blocked by hook'), 3528: command: result.command, 3529: } 3530: } 3531: return out 3532: } catch { 3533: return {} 3534: } 3535: } 3536: export async function executeElicitationHooks({ 3537: serverName, 3538: message, 3539: requestedSchema, 3540: permissionMode, 3541: signal, 3542: timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3543: mode, 3544: url, 3545: elicitationId, 3546: }: { 3547: serverName: string 3548: message: string 3549: requestedSchema?: Record<string, unknown> 3550: permissionMode?: string 3551: signal?: AbortSignal 3552: timeoutMs?: number 3553: mode?: 'form' | 'url' 3554: url?: string 3555: elicitationId?: string 3556: }): Promise<ElicitationHookResult> { 3557: const hookInput: ElicitationHookInput = { 3558: ...createBaseHookInput(permissionMode), 3559: hook_event_name: 'Elicitation', 3560: mcp_server_name: serverName, 3561: message, 3562: mode, 3563: url, 3564: elicitation_id: elicitationId, 3565: requested_schema: requestedSchema, 3566: } 3567: const results = await executeHooksOutsideREPL({ 3568: hookInput, 3569: matchQuery: serverName, 3570: signal, 3571: timeoutMs, 3572: }) 3573: let elicitationResponse: ElicitationResponse | undefined 3574: let blockingError: HookBlockingError | undefined 3575: for (const result of results) { 3576: const parsed = parseElicitationHookOutput(result, 'Elicitation') 3577: if (parsed.blockingError) { 3578: blockingError = parsed.blockingError 3579: } 3580: if (parsed.response) { 3581: elicitationResponse = parsed.response 3582: } 3583: } 3584: return { elicitationResponse, blockingError } 3585: } 3586: export async function executeElicitationResultHooks({ 3587: serverName, 3588: action, 3589: content, 3590: permissionMode, 3591: signal, 3592: timeoutMs = TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3593: mode, 3594: elicitationId, 3595: }: { 3596: serverName: string 3597: action: 'accept' | 'decline' | 'cancel' 3598: content?: Record<string, unknown> 3599: permissionMode?: string 3600: signal?: AbortSignal 3601: timeoutMs?: number 3602: mode?: 'form' | 'url' 3603: elicitationId?: string 3604: }): Promise<ElicitationResultHookResult> { 3605: const hookInput: ElicitationResultHookInput = { 3606: ...createBaseHookInput(permissionMode), 3607: hook_event_name: 'ElicitationResult', 3608: mcp_server_name: serverName, 3609: elicitation_id: elicitationId, 3610: mode, 3611: action, 3612: content, 3613: } 3614: const results = await executeHooksOutsideREPL({ 3615: hookInput, 3616: matchQuery: serverName, 3617: signal, 3618: timeoutMs, 3619: }) 3620: let elicitationResultResponse: ElicitationResponse | undefined 3621: let blockingError: HookBlockingError | undefined 3622: for (const result of results) { 3623: const parsed = parseElicitationHookOutput(result, 'ElicitationResult') 3624: if (parsed.blockingError) { 3625: blockingError = parsed.blockingError 3626: } 3627: if (parsed.response) { 3628: elicitationResultResponse = parsed.response 3629: } 3630: } 3631: return { elicitationResultResponse, blockingError } 3632: } 3633: export async function executeStatusLineCommand( 3634: statusLineInput: StatusLineCommandInput, 3635: signal?: AbortSignal, 3636: timeoutMs: number = 5000, 3637: logResult: boolean = false, 3638: ): Promise<string | undefined> { 3639: if (shouldDisableAllHooksIncludingManaged()) { 3640: return undefined 3641: } 3642: if (shouldSkipHookDueToTrust()) { 3643: logForDebugging( 3644: `Skipping StatusLine command execution - workspace trust not accepted`, 3645: ) 3646: return undefined 3647: } 3648: let statusLine 3649: if (shouldAllowManagedHooksOnly()) { 3650: statusLine = getSettingsForSource('policySettings')?.statusLine 3651: } else { 3652: statusLine = getSettings_DEPRECATED()?.statusLine 3653: } 3654: if (!statusLine || statusLine.type !== 'command') { 3655: return undefined 3656: } 3657: const abortSignal = signal || AbortSignal.timeout(timeoutMs) 3658: try { 3659: const jsonInput = jsonStringify(statusLineInput) 3660: const result = await execCommandHook( 3661: statusLine, 3662: 'StatusLine', 3663: 'statusLine', 3664: jsonInput, 3665: abortSignal, 3666: randomUUID(), 3667: ) 3668: if (result.aborted) { 3669: return undefined 3670: } 3671: if (result.status === 0) { 3672: const output = result.stdout 3673: .trim() 3674: .split('\n') 3675: .flatMap(line => line.trim() || []) 3676: .join('\n') 3677: if (output) { 3678: if (logResult) { 3679: logForDebugging( 3680: `StatusLine [${statusLine.command}] completed with status ${result.status}`, 3681: ) 3682: } 3683: return output 3684: } 3685: } else if (logResult) { 3686: logForDebugging( 3687: `StatusLine [${statusLine.command}] completed with status ${result.status}`, 3688: { level: 'warn' }, 3689: ) 3690: } 3691: return undefined 3692: } catch (error) { 3693: logForDebugging(`Status hook failed: ${error}`, { level: 'error' }) 3694: return undefined 3695: } 3696: } 3697: export async function executeFileSuggestionCommand( 3698: fileSuggestionInput: FileSuggestionCommandInput, 3699: signal?: AbortSignal, 3700: timeoutMs: number = 5000, 3701: ): Promise<string[]> { 3702: if (shouldDisableAllHooksIncludingManaged()) { 3703: return [] 3704: } 3705: if (shouldSkipHookDueToTrust()) { 3706: logForDebugging( 3707: `Skipping FileSuggestion command execution - workspace trust not accepted`, 3708: ) 3709: return [] 3710: } 3711: let fileSuggestion 3712: if (shouldAllowManagedHooksOnly()) { 3713: fileSuggestion = getSettingsForSource('policySettings')?.fileSuggestion 3714: } else { 3715: fileSuggestion = getSettings_DEPRECATED()?.fileSuggestion 3716: } 3717: if (!fileSuggestion || fileSuggestion.type !== 'command') { 3718: return [] 3719: } 3720: const abortSignal = signal || AbortSignal.timeout(timeoutMs) 3721: try { 3722: const jsonInput = jsonStringify(fileSuggestionInput) 3723: const hook = { type: 'command' as const, command: fileSuggestion.command } 3724: const result = await execCommandHook( 3725: hook, 3726: 'FileSuggestion', 3727: 'FileSuggestion', 3728: jsonInput, 3729: abortSignal, 3730: randomUUID(), 3731: ) 3732: if (result.aborted || result.status !== 0) { 3733: return [] 3734: } 3735: return result.stdout 3736: .split('\n') 3737: .map(line => line.trim()) 3738: .filter(Boolean) 3739: } catch (error) { 3740: logForDebugging(`File suggestion helper failed: ${error}`, { 3741: level: 'error', 3742: }) 3743: return [] 3744: } 3745: } 3746: async function executeFunctionHook({ 3747: hook, 3748: messages, 3749: hookName, 3750: toolUseID, 3751: hookEvent, 3752: timeoutMs, 3753: signal, 3754: }: { 3755: hook: FunctionHook 3756: messages: Message[] 3757: hookName: string 3758: toolUseID: string 3759: hookEvent: HookEvent 3760: timeoutMs: number 3761: signal?: AbortSignal 3762: }): Promise<HookResult> { 3763: const callbackTimeoutMs = hook.timeout ?? timeoutMs 3764: const { signal: abortSignal, cleanup } = createCombinedAbortSignal(signal, { 3765: timeoutMs: callbackTimeoutMs, 3766: }) 3767: try { 3768: if (abortSignal.aborted) { 3769: cleanup() 3770: return { 3771: outcome: 'cancelled', 3772: hook, 3773: } 3774: } 3775: const passed = await new Promise<boolean>((resolve, reject) => { 3776: const onAbort = () => reject(new Error('Function hook cancelled')) 3777: abortSignal.addEventListener('abort', onAbort) 3778: Promise.resolve(hook.callback(messages, abortSignal)) 3779: .then(result => { 3780: abortSignal.removeEventListener('abort', onAbort) 3781: resolve(result) 3782: }) 3783: .catch(error => { 3784: abortSignal.removeEventListener('abort', onAbort) 3785: reject(error) 3786: }) 3787: }) 3788: cleanup() 3789: if (passed) { 3790: return { 3791: outcome: 'success', 3792: hook, 3793: } 3794: } 3795: return { 3796: blockingError: { 3797: blockingError: hook.errorMessage, 3798: command: 'function', 3799: }, 3800: outcome: 'blocking', 3801: hook, 3802: } 3803: } catch (error) { 3804: cleanup() 3805: if ( 3806: error instanceof Error && 3807: (error.message === 'Function hook cancelled' || 3808: error.name === 'AbortError') 3809: ) { 3810: return { 3811: outcome: 'cancelled', 3812: hook, 3813: } 3814: } 3815: logError(error) 3816: return { 3817: message: createAttachmentMessage({ 3818: type: 'hook_error_during_execution', 3819: hookName, 3820: toolUseID, 3821: hookEvent, 3822: content: 3823: error instanceof Error 3824: ? error.message 3825: : 'Function hook execution error', 3826: }), 3827: outcome: 'non_blocking_error', 3828: hook, 3829: } 3830: } 3831: } 3832: async function executeHookCallback({ 3833: toolUseID, 3834: hook, 3835: hookEvent, 3836: hookInput, 3837: signal, 3838: hookIndex, 3839: toolUseContext, 3840: }: { 3841: toolUseID: string 3842: hook: HookCallback 3843: hookEvent: HookEvent 3844: hookInput: HookInput 3845: signal: AbortSignal 3846: hookIndex?: number 3847: toolUseContext?: ToolUseContext 3848: }): Promise<HookResult> { 3849: const context = toolUseContext 3850: ? { 3851: getAppState: toolUseContext.getAppState, 3852: updateAttributionState: toolUseContext.updateAttributionState, 3853: } 3854: : undefined 3855: const json = await hook.callback( 3856: hookInput, 3857: toolUseID, 3858: signal, 3859: hookIndex, 3860: context, 3861: ) 3862: if (isAsyncHookJSONOutput(json)) { 3863: return { 3864: outcome: 'success', 3865: hook, 3866: } 3867: } 3868: const processed = processHookJSONOutput({ 3869: json, 3870: command: 'callback', 3871: hookName: `${hookEvent}:Callback`, 3872: toolUseID, 3873: hookEvent, 3874: expectedHookEvent: hookEvent, 3875: stdout: undefined, 3876: stderr: undefined, 3877: exitCode: undefined, 3878: }) 3879: return { 3880: ...processed, 3881: outcome: 'success', 3882: hook, 3883: } 3884: } 3885: export function hasWorktreeCreateHook(): boolean { 3886: const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeCreate'] 3887: if (snapshotHooks && snapshotHooks.length > 0) return true 3888: const registeredHooks = getRegisteredHooks()?.['WorktreeCreate'] 3889: if (!registeredHooks || registeredHooks.length === 0) return false 3890: const managedOnly = shouldAllowManagedHooksOnly() 3891: return registeredHooks.some( 3892: matcher => !(managedOnly && 'pluginRoot' in matcher), 3893: ) 3894: } 3895: export async function executeWorktreeCreateHook( 3896: name: string, 3897: ): Promise<{ worktreePath: string }> { 3898: const hookInput = { 3899: ...createBaseHookInput(undefined), 3900: hook_event_name: 'WorktreeCreate' as const, 3901: name, 3902: } 3903: const results = await executeHooksOutsideREPL({ 3904: hookInput, 3905: timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3906: }) 3907: const successfulResult = results.find( 3908: r => r.succeeded && r.output.trim().length > 0, 3909: ) 3910: if (!successfulResult) { 3911: const failedOutputs = results 3912: .filter(r => !r.succeeded) 3913: .map(r => `${r.command}: ${r.output.trim() || 'no output'}`) 3914: throw new Error( 3915: `WorktreeCreate hook failed: ${failedOutputs.join('; ') || 'no successful output'}`, 3916: ) 3917: } 3918: const worktreePath = successfulResult.output.trim() 3919: return { worktreePath } 3920: } 3921: export async function executeWorktreeRemoveHook( 3922: worktreePath: string, 3923: ): Promise<boolean> { 3924: const snapshotHooks = getHooksConfigFromSnapshot()?.['WorktreeRemove'] 3925: const registeredHooks = getRegisteredHooks()?.['WorktreeRemove'] 3926: const hasSnapshotHooks = snapshotHooks && snapshotHooks.length > 0 3927: const hasRegisteredHooks = registeredHooks && registeredHooks.length > 0 3928: if (!hasSnapshotHooks && !hasRegisteredHooks) { 3929: return false 3930: } 3931: const hookInput = { 3932: ...createBaseHookInput(undefined), 3933: hook_event_name: 'WorktreeRemove' as const, 3934: worktree_path: worktreePath, 3935: } 3936: const results = await executeHooksOutsideREPL({ 3937: hookInput, 3938: timeoutMs: TOOL_HOOK_EXECUTION_TIMEOUT_MS, 3939: }) 3940: if (results.length === 0) { 3941: return false 3942: } 3943: for (const result of results) { 3944: if (!result.succeeded) { 3945: logForDebugging( 3946: `WorktreeRemove hook failed [${result.command}]: ${result.output.trim()}`, 3947: { level: 'error' }, 3948: ) 3949: } 3950: } 3951: return true 3952: } 3953: function getHookDefinitionsForTelemetry( 3954: matchedHooks: MatchedHook[], 3955: ): Array<{ type: string; command?: string; prompt?: string; name?: string }> { 3956: return matchedHooks.map(({ hook }) => { 3957: if (hook.type === 'command') { 3958: return { type: 'command', command: hook.command } 3959: } else if (hook.type === 'prompt') { 3960: return { type: 'prompt', prompt: hook.prompt } 3961: } else if (hook.type === 'http') { 3962: return { type: 'http', command: hook.url } 3963: } else if (hook.type === 'function') { 3964: return { type: 'function', name: 'function' } 3965: } else if (hook.type === 'callback') { 3966: return { type: 'callback', name: 'callback' } 3967: } 3968: return { type: 'unknown' } 3969: }) 3970: }

File: src/utils/horizontalScroll.ts

typescript 1: export type HorizontalScrollWindow = { 2: startIndex: number 3: endIndex: number 4: showLeftArrow: boolean 5: showRightArrow: boolean 6: } 7: export function calculateHorizontalScrollWindow( 8: itemWidths: number[], 9: availableWidth: number, 10: arrowWidth: number, 11: selectedIdx: number, 12: firstItemHasSeparator = true, 13: ): HorizontalScrollWindow { 14: const totalItems = itemWidths.length 15: if (totalItems === 0) { 16: return { 17: startIndex: 0, 18: endIndex: 0, 19: showLeftArrow: false, 20: showRightArrow: false, 21: } 22: } 23: const clampedSelected = Math.max(0, Math.min(selectedIdx, totalItems - 1)) 24: const totalWidth = itemWidths.reduce((sum, w) => sum + w, 0) 25: if (totalWidth <= availableWidth) { 26: return { 27: startIndex: 0, 28: endIndex: totalItems, 29: showLeftArrow: false, 30: showRightArrow: false, 31: } 32: } 33: const cumulativeWidths: number[] = [0] 34: for (let i = 0; i < totalItems; i++) { 35: cumulativeWidths.push(cumulativeWidths[i]! + itemWidths[i]!) 36: } 37: function rangeWidth(start: number, end: number): number { 38: const baseWidth = cumulativeWidths[end]! - cumulativeWidths[start]! 39: if (firstItemHasSeparator && start > 0) { 40: return baseWidth - 1 41: } 42: return baseWidth 43: } 44: function getEffectiveWidth(start: number, end: number): number { 45: let width = availableWidth 46: if (start > 0) width -= arrowWidth 47: if (end < totalItems) width -= arrowWidth 48: return width 49: } 50: let startIndex = 0 51: let endIndex = 1 52: while ( 53: endIndex < totalItems && 54: rangeWidth(startIndex, endIndex + 1) <= 55: getEffectiveWidth(startIndex, endIndex + 1) 56: ) { 57: endIndex++ 58: } 59: if (clampedSelected >= startIndex && clampedSelected < endIndex) { 60: return { 61: startIndex, 62: endIndex, 63: showLeftArrow: startIndex > 0, 64: showRightArrow: endIndex < totalItems, 65: } 66: } 67: if (clampedSelected >= endIndex) { 68: endIndex = clampedSelected + 1 69: startIndex = clampedSelected 70: while ( 71: startIndex > 0 && 72: rangeWidth(startIndex - 1, endIndex) <= 73: getEffectiveWidth(startIndex - 1, endIndex) 74: ) { 75: startIndex-- 76: } 77: } else { 78: startIndex = clampedSelected 79: endIndex = clampedSelected + 1 80: while ( 81: endIndex < totalItems && 82: rangeWidth(startIndex, endIndex + 1) <= 83: getEffectiveWidth(startIndex, endIndex + 1) 84: ) { 85: endIndex++ 86: } 87: } 88: return { 89: startIndex, 90: endIndex, 91: showLeftArrow: startIndex > 0, 92: showRightArrow: endIndex < totalItems, 93: } 94: }

File: src/utils/http.ts

typescript 1: import axios from 'axios' 2: import { OAUTH_BETA_HEADER } from '../constants/oauth.js' 3: import { 4: getAnthropicApiKey, 5: getClaudeAIOAuthTokens, 6: handleOAuth401Error, 7: isClaudeAISubscriber, 8: } from './auth.js' 9: import { getClaudeCodeUserAgent } from './userAgent.js' 10: import { getWorkload } from './workloadContext.js' 11: export function getUserAgent(): string { 12: const agentSdkVersion = process.env.CLAUDE_AGENT_SDK_VERSION 13: ? `, agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}` 14: : '' 15: // SDK consumers can identify their app/library via CLAUDE_AGENT_SDK_CLIENT_APP 16: // e.g., "my-app/1.0.0" or "my-library/2.1" 17: const clientApp = process.env.CLAUDE_AGENT_SDK_CLIENT_APP 18: ? `, client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}` 19: : '' 20: // Turn-/process-scoped workload tag for cron-initiated requests. 1P-only 21: // observability — proxies strip HTTP headers; QoS routing uses cc_workload 22: // in the billing-header attribution block instead (see constants/system.ts). 23: // getAnthropicClient (client.ts:98) calls this per-request inside withRetry, 24: // so the read picks up the same setWorkload() value as getAttributionHeader. 25: const workload = getWorkload() 26: const workloadSuffix = workload ? `, workload/${workload}` : '' 27: return `claude-cli/${MACRO.VERSION} (${process.env.USER_TYPE}, ${process.env.CLAUDE_CODE_ENTRYPOINT ?? 'cli'}${agentSdkVersion}${clientApp}${workloadSuffix})` 28: } 29: export function getMCPUserAgent(): string { 30: const parts: string[] = [] 31: if (process.env.CLAUDE_CODE_ENTRYPOINT) { 32: parts.push(process.env.CLAUDE_CODE_ENTRYPOINT) 33: } 34: if (process.env.CLAUDE_AGENT_SDK_VERSION) { 35: parts.push(`agent-sdk/${process.env.CLAUDE_AGENT_SDK_VERSION}`) 36: } 37: if (process.env.CLAUDE_AGENT_SDK_CLIENT_APP) { 38: parts.push(`client-app/${process.env.CLAUDE_AGENT_SDK_CLIENT_APP}`) 39: } 40: const suffix = parts.length > 0 ? ` (${parts.join(', ')})` : '' 41: return `claude-code/${MACRO.VERSION}${suffix}` 42: } 43: // User-Agent for WebFetch requests to arbitrary sites. `Claude-User` is 44: export function getWebFetchUserAgent(): string { 45: return `Claude-User (${getClaudeCodeUserAgent()}; +https://support.anthropic.com/)` 46: } 47: export type AuthHeaders = { 48: headers: Record<string, string> 49: error?: string 50: } 51: export function getAuthHeaders(): AuthHeaders { 52: if (isClaudeAISubscriber()) { 53: const oauthTokens = getClaudeAIOAuthTokens() 54: if (!oauthTokens?.accessToken) { 55: return { 56: headers: {}, 57: error: 'No OAuth token available', 58: } 59: } 60: return { 61: headers: { 62: Authorization: `Bearer ${oauthTokens.accessToken}`, 63: 'anthropic-beta': OAUTH_BETA_HEADER, 64: }, 65: } 66: } 67: const apiKey = getAnthropicApiKey() 68: if (!apiKey) { 69: return { 70: headers: {}, 71: error: 'No API key available', 72: } 73: } 74: return { 75: headers: { 76: 'x-api-key': apiKey, 77: }, 78: } 79: } 80: export async function withOAuth401Retry<T>( 81: request: () => Promise<T>, 82: opts?: { also403Revoked?: boolean }, 83: ): Promise<T> { 84: try { 85: return await request() 86: } catch (err) { 87: if (!axios.isAxiosError(err)) throw err 88: const status = err.response?.status 89: const isAuthError = 90: status === 401 || 91: (opts?.also403Revoked && 92: status === 403 && 93: typeof err.response?.data === 'string' && 94: err.response.data.includes('OAuth token has been revoked')) 95: if (!isAuthError) throw err 96: const failedAccessToken = getClaudeAIOAuthTokens()?.accessToken 97: if (!failedAccessToken) throw err 98: await handleOAuth401Error(failedAccessToken) 99: return await request() 100: } 101: }

File: src/utils/hyperlink.ts

typescript 1: import chalk from 'chalk' 2: import { supportsHyperlinks } from '../ink/supports-hyperlinks.js' 3: export const OSC8_START = '\x1b]8;;' 4: export const OSC8_END = '\x07' 5: type HyperlinkOptions = { 6: supportsHyperlinks?: boolean 7: } 8: export function createHyperlink( 9: url: string, 10: content?: string, 11: options?: HyperlinkOptions, 12: ): string { 13: const hasSupport = options?.supportsHyperlinks ?? supportsHyperlinks() 14: if (!hasSupport) { 15: return url 16: } 17: const displayText = content ?? url 18: const coloredText = chalk.blue(displayText) 19: return `${OSC8_START}${url}${OSC8_END}${coloredText}${OSC8_START}${OSC8_END}` 20: }

File: src/utils/ide.ts

typescript 1: import type { Client } from '@modelcontextprotocol/sdk/client/index.js' 2: import axios from 'axios' 3: import { execa } from 'execa' 4: import capitalize from 'lodash-es/capitalize.js' 5: import memoize from 'lodash-es/memoize.js' 6: import { createConnection } from 'net' 7: import * as os from 'os' 8: import { basename, join, sep as pathSeparator, resolve } from 'path' 9: import { logEvent } from 'src/services/analytics/index.js' 10: import { getIsScrollDraining, getOriginalCwd } from '../bootstrap/state.js' 11: import { callIdeRpc } from '../services/mcp/client.js' 12: import type { 13: ConnectedMCPServer, 14: MCPServerConnection, 15: } from '../services/mcp/types.js' 16: import { getGlobalConfig, saveGlobalConfig } from './config.js' 17: import { env } from './env.js' 18: import { getClaudeConfigHomeDir, isEnvTruthy } from './envUtils.js' 19: import { 20: execFileNoThrow, 21: execFileNoThrowWithCwd, 22: execSyncWithDefaults_DEPRECATED, 23: } from './execFileNoThrow.js' 24: import { getFsImplementation } from './fsOperations.js' 25: import { getAncestorPidsAsync } from './genericProcessUtils.js' 26: import { isJetBrainsPluginInstalledCached } from './jetbrains.js' 27: import { logError } from './log.js' 28: import { getPlatform } from './platform.js' 29: import { lt } from './semver.js' 30: const ideOnboardingDialog = 31: (): typeof import('src/components/IdeOnboardingDialog.js') => 32: require('src/components/IdeOnboardingDialog.js') 33: import { createAbortController } from './abortController.js' 34: import { logForDebugging } from './debug.js' 35: import { envDynamic } from './envDynamic.js' 36: import { errorMessage, isFsInaccessible } from './errors.js' 37: import { 38: checkWSLDistroMatch, 39: WindowsToWSLConverter, 40: } from './idePathConversion.js' 41: import { sleep } from './sleep.js' 42: import { jsonParse } from './slowOperations.js' 43: function isProcessRunning(pid: number): boolean { 44: try { 45: process.kill(pid, 0) 46: return true 47: } catch { 48: return false 49: } 50: } 51: function makeAncestorPidLookup(): () => Promise<Set<number>> { 52: let promise: Promise<Set<number>> | null = null 53: return () => { 54: if (!promise) { 55: promise = getAncestorPidsAsync(process.ppid, 10).then( 56: pids => new Set(pids), 57: ) 58: } 59: return promise 60: } 61: } 62: type LockfileJsonContent = { 63: workspaceFolders?: string[] 64: pid?: number 65: ideName?: string 66: transport?: 'ws' | 'sse' 67: runningInWindows?: boolean 68: authToken?: string 69: } 70: type IdeLockfileInfo = { 71: workspaceFolders: string[] 72: port: number 73: pid?: number 74: ideName?: string 75: useWebSocket: boolean 76: runningInWindows: boolean 77: authToken?: string 78: } 79: export type DetectedIDEInfo = { 80: name: string 81: port: number 82: workspaceFolders: string[] 83: url: string 84: isValid: boolean 85: authToken?: string 86: ideRunningInWindows?: boolean 87: } 88: export type IdeType = 89: | 'cursor' 90: | 'windsurf' 91: | 'vscode' 92: | 'pycharm' 93: | 'intellij' 94: | 'webstorm' 95: | 'phpstorm' 96: | 'rubymine' 97: | 'clion' 98: | 'goland' 99: | 'rider' 100: | 'datagrip' 101: | 'appcode' 102: | 'dataspell' 103: | 'aqua' 104: | 'gateway' 105: | 'fleet' 106: | 'androidstudio' 107: type IdeConfig = { 108: ideKind: 'vscode' | 'jetbrains' 109: displayName: string 110: processKeywordsMac: string[] 111: processKeywordsWindows: string[] 112: processKeywordsLinux: string[] 113: } 114: const supportedIdeConfigs: Record<IdeType, IdeConfig> = { 115: cursor: { 116: ideKind: 'vscode', 117: displayName: 'Cursor', 118: processKeywordsMac: ['Cursor Helper', 'Cursor.app'], 119: processKeywordsWindows: ['cursor.exe'], 120: processKeywordsLinux: ['cursor'], 121: }, 122: windsurf: { 123: ideKind: 'vscode', 124: displayName: 'Windsurf', 125: processKeywordsMac: ['Windsurf Helper', 'Windsurf.app'], 126: processKeywordsWindows: ['windsurf.exe'], 127: processKeywordsLinux: ['windsurf'], 128: }, 129: vscode: { 130: ideKind: 'vscode', 131: displayName: 'VS Code', 132: processKeywordsMac: ['Visual Studio Code', 'Code Helper'], 133: processKeywordsWindows: ['code.exe'], 134: processKeywordsLinux: ['code'], 135: }, 136: intellij: { 137: ideKind: 'jetbrains', 138: displayName: 'IntelliJ IDEA', 139: processKeywordsMac: ['IntelliJ IDEA'], 140: processKeywordsWindows: ['idea64.exe'], 141: processKeywordsLinux: ['idea', 'intellij'], 142: }, 143: pycharm: { 144: ideKind: 'jetbrains', 145: displayName: 'PyCharm', 146: processKeywordsMac: ['PyCharm'], 147: processKeywordsWindows: ['pycharm64.exe'], 148: processKeywordsLinux: ['pycharm'], 149: }, 150: webstorm: { 151: ideKind: 'jetbrains', 152: displayName: 'WebStorm', 153: processKeywordsMac: ['WebStorm'], 154: processKeywordsWindows: ['webstorm64.exe'], 155: processKeywordsLinux: ['webstorm'], 156: }, 157: phpstorm: { 158: ideKind: 'jetbrains', 159: displayName: 'PhpStorm', 160: processKeywordsMac: ['PhpStorm'], 161: processKeywordsWindows: ['phpstorm64.exe'], 162: processKeywordsLinux: ['phpstorm'], 163: }, 164: rubymine: { 165: ideKind: 'jetbrains', 166: displayName: 'RubyMine', 167: processKeywordsMac: ['RubyMine'], 168: processKeywordsWindows: ['rubymine64.exe'], 169: processKeywordsLinux: ['rubymine'], 170: }, 171: clion: { 172: ideKind: 'jetbrains', 173: displayName: 'CLion', 174: processKeywordsMac: ['CLion'], 175: processKeywordsWindows: ['clion64.exe'], 176: processKeywordsLinux: ['clion'], 177: }, 178: goland: { 179: ideKind: 'jetbrains', 180: displayName: 'GoLand', 181: processKeywordsMac: ['GoLand'], 182: processKeywordsWindows: ['goland64.exe'], 183: processKeywordsLinux: ['goland'], 184: }, 185: rider: { 186: ideKind: 'jetbrains', 187: displayName: 'Rider', 188: processKeywordsMac: ['Rider'], 189: processKeywordsWindows: ['rider64.exe'], 190: processKeywordsLinux: ['rider'], 191: }, 192: datagrip: { 193: ideKind: 'jetbrains', 194: displayName: 'DataGrip', 195: processKeywordsMac: ['DataGrip'], 196: processKeywordsWindows: ['datagrip64.exe'], 197: processKeywordsLinux: ['datagrip'], 198: }, 199: appcode: { 200: ideKind: 'jetbrains', 201: displayName: 'AppCode', 202: processKeywordsMac: ['AppCode'], 203: processKeywordsWindows: ['appcode.exe'], 204: processKeywordsLinux: ['appcode'], 205: }, 206: dataspell: { 207: ideKind: 'jetbrains', 208: displayName: 'DataSpell', 209: processKeywordsMac: ['DataSpell'], 210: processKeywordsWindows: ['dataspell64.exe'], 211: processKeywordsLinux: ['dataspell'], 212: }, 213: aqua: { 214: ideKind: 'jetbrains', 215: displayName: 'Aqua', 216: processKeywordsMac: [], 217: processKeywordsWindows: ['aqua64.exe'], 218: processKeywordsLinux: [], 219: }, 220: gateway: { 221: ideKind: 'jetbrains', 222: displayName: 'Gateway', 223: processKeywordsMac: [], 224: processKeywordsWindows: ['gateway64.exe'], 225: processKeywordsLinux: [], 226: }, 227: fleet: { 228: ideKind: 'jetbrains', 229: displayName: 'Fleet', 230: processKeywordsMac: [], 231: processKeywordsWindows: ['fleet.exe'], 232: processKeywordsLinux: [], 233: }, 234: androidstudio: { 235: ideKind: 'jetbrains', 236: displayName: 'Android Studio', 237: processKeywordsMac: ['Android Studio'], 238: processKeywordsWindows: ['studio64.exe'], 239: processKeywordsLinux: ['android-studio'], 240: }, 241: } 242: export function isVSCodeIde(ide: IdeType | null): boolean { 243: if (!ide) return false 244: const config = supportedIdeConfigs[ide] 245: return config && config.ideKind === 'vscode' 246: } 247: export function isJetBrainsIde(ide: IdeType | null): boolean { 248: if (!ide) return false 249: const config = supportedIdeConfigs[ide] 250: return config && config.ideKind === 'jetbrains' 251: } 252: export const isSupportedVSCodeTerminal = memoize(() => { 253: return isVSCodeIde(env.terminal as IdeType) 254: }) 255: export const isSupportedJetBrainsTerminal = memoize(() => { 256: return isJetBrainsIde(envDynamic.terminal as IdeType) 257: }) 258: export const isSupportedTerminal = memoize(() => { 259: return ( 260: isSupportedVSCodeTerminal() || 261: isSupportedJetBrainsTerminal() || 262: Boolean(process.env.FORCE_CODE_TERMINAL) 263: ) 264: }) 265: export function getTerminalIdeType(): IdeType | null { 266: if (!isSupportedTerminal()) { 267: return null 268: } 269: return env.terminal as IdeType 270: } 271: export async function getSortedIdeLockfiles(): Promise<string[]> { 272: try { 273: const ideLockFilePaths = await getIdeLockfilesPaths() 274: const allLockfiles: Array<{ path: string; mtime: Date }>[] = 275: await Promise.all( 276: ideLockFilePaths.map(async ideLockFilePath => { 277: try { 278: const entries = await getFsImplementation().readdir(ideLockFilePath) 279: const lockEntries = entries.filter(file => 280: file.name.endsWith('.lock'), 281: ) 282: const stats = await Promise.all( 283: lockEntries.map(async file => { 284: const fullPath = join(ideLockFilePath, file.name) 285: try { 286: const fileStat = await getFsImplementation().stat(fullPath) 287: return { path: fullPath, mtime: fileStat.mtime } 288: } catch { 289: return null 290: } 291: }), 292: ) 293: return stats.filter(s => s !== null) 294: } catch (error) { 295: if (!isFsInaccessible(error)) { 296: logError(error) 297: } 298: return [] 299: } 300: }), 301: ) 302: return allLockfiles 303: .flat() 304: .sort((a, b) => b.mtime.getTime() - a.mtime.getTime()) 305: .map(file => file.path) 306: } catch (error) { 307: logError(error as Error) 308: return [] 309: } 310: } 311: async function readIdeLockfile(path: string): Promise<IdeLockfileInfo | null> { 312: try { 313: const content = await getFsImplementation().readFile(path, { 314: encoding: 'utf-8', 315: }) 316: let workspaceFolders: string[] = [] 317: let pid: number | undefined 318: let ideName: string | undefined 319: let useWebSocket = false 320: let runningInWindows = false 321: let authToken: string | undefined 322: try { 323: const parsedContent = jsonParse(content) as LockfileJsonContent 324: if (parsedContent.workspaceFolders) { 325: workspaceFolders = parsedContent.workspaceFolders 326: } 327: pid = parsedContent.pid 328: ideName = parsedContent.ideName 329: useWebSocket = parsedContent.transport === 'ws' 330: runningInWindows = parsedContent.runningInWindows === true 331: authToken = parsedContent.authToken 332: } catch (_) { 333: workspaceFolders = content.split('\n').map(line => line.trim()) 334: } 335: const filename = path.split(pathSeparator).pop() 336: if (!filename) return null 337: const port = filename.replace('.lock', '') 338: return { 339: workspaceFolders, 340: port: parseInt(port), 341: pid, 342: ideName, 343: useWebSocket, 344: runningInWindows, 345: authToken, 346: } 347: } catch (error) { 348: logError(error as Error) 349: return null 350: } 351: } 352: /** 353: * Checks if the IDE connection is responding by testing if the port is open 354: * @param host Host to connect to 355: * @param port Port to connect to 356: * @param timeout Optional timeout in milliseconds (defaults to 500ms) 357: * @returns true if the port is open, false otherwise 358: */ 359: async function checkIdeConnection( 360: host: string, 361: port: number, 362: timeout = 500, 363: ): Promise<boolean> { 364: try { 365: return new Promise(resolve => { 366: const socket = createConnection({ 367: host: host, 368: port: port, 369: timeout: timeout, 370: }) 371: socket.on('connect', () => { 372: socket.destroy() 373: void resolve(true) 374: }) 375: socket.on('error', () => { 376: void resolve(false) 377: }) 378: socket.on('timeout', () => { 379: socket.destroy() 380: void resolve(false) 381: }) 382: }) 383: } catch (_) { 384: return false 385: } 386: } 387: const getWindowsUserProfile = memoize(async (): Promise<string | undefined> => { 388: if (process.env.USERPROFILE) return process.env.USERPROFILE 389: const { stdout, code } = await execFileNoThrow('powershell.exe', [ 390: '-NoProfile', 391: '-NonInteractive', 392: '-Command', 393: '$env:USERPROFILE', 394: ]) 395: if (code === 0 && stdout.trim()) return stdout.trim() 396: logForDebugging( 397: 'Unable to get Windows USERPROFILE via PowerShell - IDE detection may be incomplete', 398: ) 399: return undefined 400: }) 401: export async function getIdeLockfilesPaths(): Promise<string[]> { 402: const paths: string[] = [join(getClaudeConfigHomeDir(), 'ide')] 403: if (getPlatform() !== 'wsl') { 404: return paths 405: } 406: const windowsHome = await getWindowsUserProfile() 407: if (windowsHome) { 408: const converter = new WindowsToWSLConverter(process.env.WSL_DISTRO_NAME) 409: const wslPath = converter.toLocalPath(windowsHome) 410: paths.push(resolve(wslPath, '.claude', 'ide')) 411: } 412: try { 413: const usersDir = '/mnt/c/Users' 414: const userDirs = await getFsImplementation().readdir(usersDir) 415: for (const user of userDirs) { 416: if (!user.isDirectory() && !user.isSymbolicLink()) { 417: continue 418: } 419: if ( 420: user.name === 'Public' || 421: user.name === 'Default' || 422: user.name === 'Default User' || 423: user.name === 'All Users' 424: ) { 425: continue 426: } 427: paths.push(join(usersDir, user.name, '.claude', 'ide')) 428: } 429: } catch (error: unknown) { 430: if (isFsInaccessible(error)) { 431: logForDebugging( 432: `WSL IDE lockfile path detection failed (${error.code}): ${errorMessage(error)}`, 433: ) 434: } else { 435: logError(error) 436: } 437: } 438: return paths 439: } 440: export async function cleanupStaleIdeLockfiles(): Promise<void> { 441: try { 442: const lockfiles = await getSortedIdeLockfiles() 443: for (const lockfilePath of lockfiles) { 444: const lockfileInfo = await readIdeLockfile(lockfilePath) 445: if (!lockfileInfo) { 446: try { 447: await getFsImplementation().unlink(lockfilePath) 448: } catch (error) { 449: logError(error as Error) 450: } 451: continue 452: } 453: const host = await detectHostIP( 454: lockfileInfo.runningInWindows, 455: lockfileInfo.port, 456: ) 457: let shouldDelete = false 458: if (lockfileInfo.pid) { 459: if (!isProcessRunning(lockfileInfo.pid)) { 460: if (getPlatform() !== 'wsl') { 461: shouldDelete = true 462: } else { 463: const isResponding = await checkIdeConnection( 464: host, 465: lockfileInfo.port, 466: ) 467: if (!isResponding) { 468: shouldDelete = true 469: } 470: } 471: } 472: } else { 473: const isResponding = await checkIdeConnection(host, lockfileInfo.port) 474: if (!isResponding) { 475: shouldDelete = true 476: } 477: } 478: if (shouldDelete) { 479: try { 480: await getFsImplementation().unlink(lockfilePath) 481: } catch (error) { 482: logError(error as Error) 483: } 484: } 485: } 486: } catch (error) { 487: logError(error as Error) 488: } 489: } 490: export interface IDEExtensionInstallationStatus { 491: installed: boolean 492: error: string | null 493: installedVersion: string | null 494: ideType: IdeType | null 495: } 496: export async function maybeInstallIDEExtension( 497: ideType: IdeType, 498: ): Promise<IDEExtensionInstallationStatus | null> { 499: try { 500: const installedVersion = await installIDEExtension(ideType) 501: logEvent('tengu_ext_installed', {}) 502: const globalConfig = getGlobalConfig() 503: if (!globalConfig.diffTool) { 504: saveGlobalConfig(current => ({ ...current, diffTool: 'auto' })) 505: } 506: return { 507: installed: true, 508: error: null, 509: installedVersion, 510: ideType: ideType, 511: } 512: } catch (error) { 513: logEvent('tengu_ext_install_error', {}) 514: const errorMessage = error instanceof Error ? error.message : String(error) 515: logError(error as Error) 516: return { 517: installed: false, 518: error: errorMessage, 519: installedVersion: null, 520: ideType: ideType, 521: } 522: } 523: } 524: let currentIDESearch: AbortController | null = null 525: export async function findAvailableIDE(): Promise<DetectedIDEInfo | null> { 526: if (currentIDESearch) { 527: currentIDESearch.abort() 528: } 529: currentIDESearch = createAbortController() 530: const signal = currentIDESearch.signal 531: await cleanupStaleIdeLockfiles() 532: const startTime = Date.now() 533: while (Date.now() - startTime < 30_000 && !signal.aborted) { 534: if (getIsScrollDraining()) { 535: await sleep(1000, signal) 536: continue 537: } 538: const ides = await detectIDEs(false) 539: if (signal.aborted) { 540: return null 541: } 542: if (ides.length === 1) { 543: return ides[0]! 544: } 545: await sleep(1000, signal) 546: } 547: return null 548: } 549: export async function detectIDEs( 550: includeInvalid: boolean, 551: ): Promise<DetectedIDEInfo[]> { 552: const detectedIDEs: DetectedIDEInfo[] = [] 553: try { 554: const ssePort = process.env.CLAUDE_CODE_SSE_PORT 555: const envPort = ssePort ? parseInt(ssePort) : null 556: const cwd = getOriginalCwd().normalize('NFC') 557: const lockfiles = await getSortedIdeLockfiles() 558: const lockfileInfos = await Promise.all(lockfiles.map(readIdeLockfile)) 559: const getAncestors = makeAncestorPidLookup() 560: const needsAncestryCheck = getPlatform() !== 'wsl' && isSupportedTerminal() 561: for (const lockfileInfo of lockfileInfos) { 562: if (!lockfileInfo) continue 563: let isValid = false 564: if (isEnvTruthy(process.env.CLAUDE_CODE_IDE_SKIP_VALID_CHECK)) { 565: isValid = true 566: } else if (lockfileInfo.port === envPort) { 567: isValid = true 568: } else { 569: isValid = lockfileInfo.workspaceFolders.some(idePath => { 570: if (!idePath) return false 571: let localPath = idePath 572: if ( 573: getPlatform() === 'wsl' && 574: lockfileInfo.runningInWindows && 575: process.env.WSL_DISTRO_NAME 576: ) { 577: if (!checkWSLDistroMatch(idePath, process.env.WSL_DISTRO_NAME)) { 578: return false 579: } 580: const resolvedOriginal = resolve(localPath).normalize('NFC') 581: if ( 582: cwd === resolvedOriginal || 583: cwd.startsWith(resolvedOriginal + pathSeparator) 584: ) { 585: return true 586: } 587: const converter = new WindowsToWSLConverter( 588: process.env.WSL_DISTRO_NAME, 589: ) 590: localPath = converter.toLocalPath(idePath) 591: } 592: const resolvedPath = resolve(localPath).normalize('NFC') 593: if (getPlatform() === 'windows') { 594: const normalizedCwd = cwd.replace(/^[a-zA-Z]:/, match => 595: match.toUpperCase(), 596: ) 597: const normalizedResolvedPath = resolvedPath.replace( 598: /^[a-zA-Z]:/, 599: match => match.toUpperCase(), 600: ) 601: return ( 602: normalizedCwd === normalizedResolvedPath || 603: normalizedCwd.startsWith(normalizedResolvedPath + pathSeparator) 604: ) 605: } 606: return ( 607: cwd === resolvedPath || cwd.startsWith(resolvedPath + pathSeparator) 608: ) 609: }) 610: } 611: if (!isValid && !includeInvalid) { 612: continue 613: } 614: if (needsAncestryCheck) { 615: const portMatchesEnv = envPort !== null && lockfileInfo.port === envPort 616: if (!portMatchesEnv) { 617: if (!lockfileInfo.pid || !isProcessRunning(lockfileInfo.pid)) { 618: continue 619: } 620: if (process.ppid !== lockfileInfo.pid) { 621: const ancestors = await getAncestors() 622: if (!ancestors.has(lockfileInfo.pid)) { 623: continue 624: } 625: } 626: } 627: } 628: const ideName = 629: lockfileInfo.ideName ?? 630: (isSupportedTerminal() ? toIDEDisplayName(envDynamic.terminal) : 'IDE') 631: const host = await detectHostIP( 632: lockfileInfo.runningInWindows, 633: lockfileInfo.port, 634: ) 635: let url 636: if (lockfileInfo.useWebSocket) { 637: url = `ws://${host}:${lockfileInfo.port}` 638: } else { 639: url = `http://${host}:${lockfileInfo.port}/sse` 640: } 641: detectedIDEs.push({ 642: url: url, 643: name: ideName, 644: workspaceFolders: lockfileInfo.workspaceFolders, 645: port: lockfileInfo.port, 646: isValid: isValid, 647: authToken: lockfileInfo.authToken, 648: ideRunningInWindows: lockfileInfo.runningInWindows, 649: }) 650: } 651: if (!includeInvalid && envPort) { 652: const envPortMatch = detectedIDEs.filter( 653: ide => ide.isValid && ide.port === envPort, 654: ) 655: if (envPortMatch.length === 1) { 656: return envPortMatch 657: } 658: } 659: } catch (error) { 660: logError(error as Error) 661: } 662: return detectedIDEs 663: } 664: export async function maybeNotifyIDEConnected(client: Client) { 665: await client.notification({ 666: method: 'ide_connected', 667: params: { 668: pid: process.pid, 669: }, 670: }) 671: } 672: export function hasAccessToIDEExtensionDiffFeature( 673: mcpClients: MCPServerConnection[], 674: ): boolean { 675: return mcpClients.some( 676: client => client.type === 'connected' && client.name === 'ide', 677: ) 678: } 679: const EXTENSION_ID = 680: process.env.USER_TYPE === 'ant' 681: ? 'anthropic.claude-code-internal' 682: : 'anthropic.claude-code' 683: export async function isIDEExtensionInstalled( 684: ideType: IdeType, 685: ): Promise<boolean> { 686: if (isVSCodeIde(ideType)) { 687: const command = await getVSCodeIDECommand(ideType) 688: if (command) { 689: try { 690: const result = await execFileNoThrowWithCwd( 691: command, 692: ['--list-extensions'], 693: { 694: env: getInstallationEnv(), 695: }, 696: ) 697: if (result.stdout?.includes(EXTENSION_ID)) { 698: return true 699: } 700: } catch { 701: } 702: } 703: } else if (isJetBrainsIde(ideType)) { 704: return await isJetBrainsPluginInstalledCached(ideType) 705: } 706: return false 707: } 708: async function installIDEExtension(ideType: IdeType): Promise<string | null> { 709: if (isVSCodeIde(ideType)) { 710: const command = await getVSCodeIDECommand(ideType) 711: if (command) { 712: if (process.env.USER_TYPE === 'ant') { 713: return await installFromArtifactory(command) 714: } 715: let version = await getInstalledVSCodeExtensionVersion(command) 716: if (!version || lt(version, getClaudeCodeVersion())) { 717: await sleep(500) 718: const result = await execFileNoThrowWithCwd( 719: command, 720: ['--force', '--install-extension', 'anthropic.claude-code'], 721: { 722: env: getInstallationEnv(), 723: }, 724: ) 725: if (result.code !== 0) { 726: throw new Error(`${result.code}: ${result.error} ${result.stderr}`) 727: } 728: version = getClaudeCodeVersion() 729: } 730: return version 731: } 732: } 733: return null 734: } 735: function getInstallationEnv(): NodeJS.ProcessEnv | undefined { 736: if (getPlatform() === 'linux') { 737: return { 738: ...process.env, 739: DISPLAY: '', 740: } 741: } 742: return undefined 743: } 744: function getClaudeCodeVersion() { 745: return MACRO.VERSION 746: } 747: async function getInstalledVSCodeExtensionVersion( 748: command: string, 749: ): Promise<string | null> { 750: const { stdout } = await execFileNoThrow( 751: command, 752: ['--list-extensions', '--show-versions'], 753: { 754: env: getInstallationEnv(), 755: }, 756: ) 757: const lines = stdout?.split('\n') || [] 758: for (const line of lines) { 759: const [extensionId, version] = line.split('@') 760: if (extensionId === 'anthropic.claude-code' && version) { 761: return version 762: } 763: } 764: return null 765: } 766: function getVSCodeIDECommandByParentProcess(): string | null { 767: try { 768: const platform = getPlatform() 769: if (platform !== 'macos') { 770: return null 771: } 772: let pid = process.ppid 773: for (let i = 0; i < 10; i++) { 774: if (!pid || pid === 0 || pid === 1) break 775: const command = execSyncWithDefaults_DEPRECATED( 776: `ps -o command= -p ${pid}`, 777: )?.trim() 778: if (command) { 779: const appNames = { 780: 'Visual Studio Code.app': 'code', 781: 'Cursor.app': 'cursor', 782: 'Windsurf.app': 'windsurf', 783: 'Visual Studio Code - Insiders.app': 'code', 784: 'VSCodium.app': 'codium', 785: } 786: const pathToExecutable = '/Contents/MacOS/Electron' 787: for (const [appName, executableName] of Object.entries(appNames)) { 788: const appIndex = command.indexOf(appName + pathToExecutable) 789: if (appIndex !== -1) { 790: const folderPathEnd = appIndex + appName.length 791: return ( 792: command.substring(0, folderPathEnd) + 793: '/Contents/Resources/app/bin/' + 794: executableName 795: ) 796: } 797: } 798: } 799: const ppidStr = execSyncWithDefaults_DEPRECATED( 800: `ps -o ppid= -p ${pid}`, 801: )?.trim() 802: if (!ppidStr) { 803: break 804: } 805: pid = parseInt(ppidStr.trim()) 806: } 807: return null 808: } catch { 809: return null 810: } 811: } 812: async function getVSCodeIDECommand(ideType: IdeType): Promise<string | null> { 813: const parentExecutable = getVSCodeIDECommandByParentProcess() 814: if (parentExecutable) { 815: try { 816: await getFsImplementation().stat(parentExecutable) 817: return parentExecutable 818: } catch { 819: } 820: } 821: const ext = getPlatform() === 'windows' ? '.cmd' : '' 822: switch (ideType) { 823: case 'vscode': 824: return 'code' + ext 825: case 'cursor': 826: return 'cursor' + ext 827: case 'windsurf': 828: return 'windsurf' + ext 829: default: 830: break 831: } 832: return null 833: } 834: export async function isCursorInstalled(): Promise<boolean> { 835: const result = await execFileNoThrow('cursor', ['--version']) 836: return result.code === 0 837: } 838: export async function isWindsurfInstalled(): Promise<boolean> { 839: const result = await execFileNoThrow('windsurf', ['--version']) 840: return result.code === 0 841: } 842: export async function isVSCodeInstalled(): Promise<boolean> { 843: const result = await execFileNoThrow('code', ['--help']) 844: return ( 845: result.code === 0 && Boolean(result.stdout?.includes('Visual Studio Code')) 846: ) 847: } 848: let cachedRunningIDEs: IdeType[] | null = null 849: async function detectRunningIDEsImpl(): Promise<IdeType[]> { 850: const runningIDEs: IdeType[] = [] 851: try { 852: const platform = getPlatform() 853: if (platform === 'macos') { 854: const result = await execa( 855: 'ps aux | grep -E "Visual Studio Code|Code Helper|Cursor Helper|Windsurf Helper|IntelliJ IDEA|PyCharm|WebStorm|PhpStorm|RubyMine|CLion|GoLand|Rider|DataGrip|AppCode|DataSpell|Aqua|Gateway|Fleet|Android Studio" | grep -v grep', 856: { shell: true, reject: false }, 857: ) 858: const stdout = result.stdout ?? '' 859: for (const [ide, config] of Object.entries(supportedIdeConfigs)) { 860: for (const keyword of config.processKeywordsMac) { 861: if (stdout.includes(keyword)) { 862: runningIDEs.push(ide as IdeType) 863: break 864: } 865: } 866: } 867: } else if (platform === 'windows') { 868: const result = await execa( 869: 'tasklist | findstr /I "Code.exe Cursor.exe Windsurf.exe idea64.exe pycharm64.exe webstorm64.exe phpstorm64.exe rubymine64.exe clion64.exe goland64.exe rider64.exe datagrip64.exe appcode.exe dataspell64.exe aqua64.exe gateway64.exe fleet.exe studio64.exe"', 870: { shell: true, reject: false }, 871: ) 872: const stdout = result.stdout ?? '' 873: const normalizedStdout = stdout.toLowerCase() 874: for (const [ide, config] of Object.entries(supportedIdeConfigs)) { 875: for (const keyword of config.processKeywordsWindows) { 876: if (normalizedStdout.includes(keyword.toLowerCase())) { 877: runningIDEs.push(ide as IdeType) 878: break 879: } 880: } 881: } 882: } else if (platform === 'linux') { 883: const result = await execa( 884: 'ps aux | grep -E "code|cursor|windsurf|idea|pycharm|webstorm|phpstorm|rubymine|clion|goland|rider|datagrip|dataspell|aqua|gateway|fleet|android-studio" | grep -v grep', 885: { shell: true, reject: false }, 886: ) 887: const stdout = result.stdout ?? '' 888: const normalizedStdout = stdout.toLowerCase() 889: for (const [ide, config] of Object.entries(supportedIdeConfigs)) { 890: for (const keyword of config.processKeywordsLinux) { 891: if (normalizedStdout.includes(keyword)) { 892: if (ide !== 'vscode') { 893: runningIDEs.push(ide as IdeType) 894: break 895: } else if ( 896: !normalizedStdout.includes('cursor') && 897: !normalizedStdout.includes('appcode') 898: ) { 899: runningIDEs.push(ide as IdeType) 900: break 901: } 902: } 903: } 904: } 905: } 906: } catch (error) { 907: logError(error as Error) 908: } 909: return runningIDEs 910: } 911: export async function detectRunningIDEs(): Promise<IdeType[]> { 912: const result = await detectRunningIDEsImpl() 913: cachedRunningIDEs = result 914: return result 915: } 916: export async function detectRunningIDEsCached(): Promise<IdeType[]> { 917: if (cachedRunningIDEs === null) { 918: return detectRunningIDEs() 919: } 920: return cachedRunningIDEs 921: } 922: export function resetDetectRunningIDEs(): void { 923: cachedRunningIDEs = null 924: } 925: export function getConnectedIdeName( 926: mcpClients: MCPServerConnection[], 927: ): string | null { 928: const ideClient = mcpClients.find( 929: client => client.type === 'connected' && client.name === 'ide', 930: ) 931: return getIdeClientName(ideClient) 932: } 933: export function getIdeClientName( 934: ideClient?: MCPServerConnection, 935: ): string | null { 936: const config = ideClient?.config 937: return config?.type === 'sse-ide' || config?.type === 'ws-ide' 938: ? config.ideName 939: : isSupportedTerminal() 940: ? toIDEDisplayName(envDynamic.terminal) 941: : null 942: } 943: const EDITOR_DISPLAY_NAMES: Record<string, string> = { 944: code: 'VS Code', 945: cursor: 'Cursor', 946: windsurf: 'Windsurf', 947: antigravity: 'Antigravity', 948: vi: 'Vim', 949: vim: 'Vim', 950: nano: 'nano', 951: notepad: 'Notepad', 952: 'start /wait notepad': 'Notepad', 953: emacs: 'Emacs', 954: subl: 'Sublime Text', 955: atom: 'Atom', 956: } 957: export function toIDEDisplayName(terminal: string | null): string { 958: if (!terminal) return 'IDE' 959: const config = supportedIdeConfigs[terminal as IdeType] 960: if (config) { 961: return config.displayName 962: } 963: const editorName = EDITOR_DISPLAY_NAMES[terminal.toLowerCase().trim()] 964: if (editorName) { 965: return editorName 966: } 967: const command = terminal.split(' ')[0] 968: const commandName = command ? basename(command).toLowerCase() : null 969: if (commandName) { 970: const mappedName = EDITOR_DISPLAY_NAMES[commandName] 971: if (mappedName) { 972: return mappedName 973: } 974: return capitalize(commandName) 975: } 976: return capitalize(terminal) 977: } 978: export { callIdeRpc } 979: export function getConnectedIdeClient( 980: mcpClients?: MCPServerConnection[], 981: ): ConnectedMCPServer | undefined { 982: if (!mcpClients) { 983: return undefined 984: } 985: const ideClient = mcpClients.find( 986: client => client.type === 'connected' && client.name === 'ide', 987: ) 988: return ideClient?.type === 'connected' ? ideClient : undefined 989: } 990: export async function closeOpenDiffs( 991: ideClient: ConnectedMCPServer, 992: ): Promise<void> { 993: try { 994: await callIdeRpc('closeAllDiffTabs', {}, ideClient) 995: } catch (_) { 996: } 997: } 998: export async function initializeIdeIntegration( 999: onIdeDetected: (ide: DetectedIDEInfo | null) => void, 1000: ideToInstallExtension: IdeType | null, 1001: onShowIdeOnboarding: () => void, 1002: onInstallationComplete: ( 1003: status: IDEExtensionInstallationStatus | null, 1004: ) => void, 1005: ): Promise<void> { 1006: void findAvailableIDE().then(onIdeDetected) 1007: const shouldAutoInstall = getGlobalConfig().autoInstallIdeExtension ?? true 1008: if ( 1009: !isEnvTruthy(process.env.CLAUDE_CODE_IDE_SKIP_AUTO_INSTALL) && 1010: shouldAutoInstall 1011: ) { 1012: const ideType = ideToInstallExtension ?? getTerminalIdeType() 1013: if (ideType) { 1014: if (isVSCodeIde(ideType)) { 1015: void isIDEExtensionInstalled(ideType).then(async isAlreadyInstalled => { 1016: void maybeInstallIDEExtension(ideType) 1017: .catch(error => { 1018: const ideInstallationStatus: IDEExtensionInstallationStatus = { 1019: installed: false, 1020: error: error.message || 'Installation failed', 1021: installedVersion: null, 1022: ideType: ideType, 1023: } 1024: return ideInstallationStatus 1025: }) 1026: .then(status => { 1027: onInstallationComplete(status) 1028: if (status?.installed) { 1029: void findAvailableIDE().then(onIdeDetected) 1030: } 1031: if ( 1032: !isAlreadyInstalled && 1033: status?.installed === true && 1034: !ideOnboardingDialog().hasIdeOnboardingDialogBeenShown() 1035: ) { 1036: onShowIdeOnboarding() 1037: } 1038: }) 1039: }) 1040: } else if (isJetBrainsIde(ideType)) { 1041: void isIDEExtensionInstalled(ideType).then(async installed => { 1042: if ( 1043: installed && 1044: !ideOnboardingDialog().hasIdeOnboardingDialogBeenShown() 1045: ) { 1046: onShowIdeOnboarding() 1047: } 1048: }) 1049: } 1050: } 1051: } 1052: } 1053: const detectHostIP = memoize( 1054: async (isIdeRunningInWindows: boolean, port: number) => { 1055: if (process.env.CLAUDE_CODE_IDE_HOST_OVERRIDE) { 1056: return process.env.CLAUDE_CODE_IDE_HOST_OVERRIDE 1057: } 1058: if (getPlatform() !== 'wsl' || !isIdeRunningInWindows) { 1059: return '127.0.0.1' 1060: } 1061: try { 1062: const routeResult = await execa('ip route show | grep -i default', { 1063: shell: true, 1064: reject: false, 1065: }) 1066: if (routeResult.exitCode === 0 && routeResult.stdout) { 1067: const gatewayMatch = routeResult.stdout.match( 1068: /default via (\d+\.\d+\.\d+\.\d+)/, 1069: ) 1070: if (gatewayMatch) { 1071: const gatewayIP = gatewayMatch[1]! 1072: if (await checkIdeConnection(gatewayIP, port)) { 1073: return gatewayIP 1074: } 1075: } 1076: } 1077: } catch (_) { 1078: } 1079: return '127.0.0.1' 1080: }, 1081: (isIdeRunningInWindows, port) => `${isIdeRunningInWindows}:${port}`, 1082: ) 1083: async function installFromArtifactory(command: string): Promise<string> { 1084: const npmrcPath = join(os.homedir(), '.npmrc') 1085: let authToken: string | null = null 1086: const fs = getFsImplementation() 1087: try { 1088: const npmrcContent = await fs.readFile(npmrcPath, { 1089: encoding: 'utf8', 1090: }) 1091: const lines = npmrcContent.split('\n') 1092: for (const line of lines) { 1093: const match = line.match( 1094: /\/\/artifactory\.infra\.ant\.dev\/artifactory\/api\/npm\/npm-all\/:_authToken=(.+)/, 1095: ) 1096: if (match && match[1]) { 1097: authToken = match[1].trim() 1098: break 1099: } 1100: } 1101: } catch (error) { 1102: logError(error as Error) 1103: throw new Error(`Failed to read npm authentication: ${error}`) 1104: } 1105: if (!authToken) { 1106: throw new Error('No artifactory auth token found in ~/.npmrc') 1107: } 1108: const versionUrl = 1109: 'https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/stable' 1110: try { 1111: const versionResponse = await axios.get(versionUrl, { 1112: headers: { 1113: Authorization: `Bearer ${authToken}`, 1114: }, 1115: }) 1116: const version = versionResponse.data.trim() 1117: if (!version) { 1118: throw new Error('No version found in artifactory response') 1119: } 1120: const vsixUrl = `https://artifactory.infra.ant.dev/artifactory/armorcode-claude-code-internal/claude-vscode-releases/${version}/claude-code.vsix` 1121: const tempVsixPath = join( 1122: os.tmpdir(), 1123: `claude-code-${version}-${Date.now()}.vsix`, 1124: ) 1125: try { 1126: const vsixResponse = await axios.get(vsixUrl, { 1127: headers: { 1128: Authorization: `Bearer ${authToken}`, 1129: }, 1130: responseType: 'stream', 1131: }) 1132: const writeStream = getFsImplementation().createWriteStream(tempVsixPath) 1133: await new Promise<void>((resolve, reject) => { 1134: vsixResponse.data.pipe(writeStream) 1135: writeStream.on('finish', resolve) 1136: writeStream.on('error', reject) 1137: }) 1138: await sleep(500) 1139: const result = await execFileNoThrowWithCwd( 1140: command, 1141: ['--force', '--install-extension', tempVsixPath], 1142: { 1143: env: getInstallationEnv(), 1144: }, 1145: ) 1146: if (result.code !== 0) { 1147: throw new Error(`${result.code}: ${result.error} ${result.stderr}`) 1148: } 1149: return version 1150: } finally { 1151: try { 1152: await fs.unlink(tempVsixPath) 1153: } catch { 1154: } 1155: } 1156: } catch (error) { 1157: if (axios.isAxiosError(error)) { 1158: throw new Error( 1159: `Failed to fetch extension version from artifactory: ${error.message}`, 1160: ) 1161: } 1162: throw error 1163: } 1164: }

File: src/utils/idePathConversion.ts

typescript 1: import { execFileSync } from 'child_process' 2: export interface IDEPathConverter { 3: toLocalPath(idePath: string): string 4: toIDEPath(localPath: string): string 5: } 6: export class WindowsToWSLConverter implements IDEPathConverter { 7: constructor(private wslDistroName: string | undefined) {} 8: toLocalPath(windowsPath: string): string { 9: if (!windowsPath) return windowsPath 10: if (this.wslDistroName) { 11: const wslUncMatch = windowsPath.match( 12: /^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(.*)$/, 13: ) 14: if (wslUncMatch && wslUncMatch[1] !== this.wslDistroName) { 15: return windowsPath 16: } 17: } 18: try { 19: const result = execFileSync('wslpath', ['-u', windowsPath], { 20: encoding: 'utf8', 21: stdio: ['pipe', 'pipe', 'ignore'], 22: }).trim() 23: return result 24: } catch { 25: return windowsPath 26: .replace(/\\/g, '/') 27: .replace(/^([A-Z]):/i, (_, letter) => `/mnt/${letter.toLowerCase()}`) 28: } 29: } 30: toIDEPath(wslPath: string): string { 31: if (!wslPath) return wslPath 32: try { 33: const result = execFileSync('wslpath', ['-w', wslPath], { 34: encoding: 'utf8', 35: stdio: ['pipe', 'pipe', 'ignore'], 36: }).trim() 37: return result 38: } catch { 39: return wslPath 40: } 41: } 42: } 43: export function checkWSLDistroMatch( 44: windowsPath: string, 45: wslDistroName: string, 46: ): boolean { 47: const wslUncMatch = windowsPath.match( 48: /^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(.*)$/, 49: ) 50: if (wslUncMatch) { 51: return wslUncMatch[1] === wslDistroName 52: } 53: return true 54: }

File: src/utils/idleTimeout.ts

typescript 1: import { logForDebugging } from './debug.js' 2: import { gracefulShutdownSync } from './gracefulShutdown.js' 3: export function createIdleTimeoutManager(isIdle: () => boolean): { 4: start: () => void 5: stop: () => void 6: } { 7: const exitAfterStopDelay = process.env.CLAUDE_CODE_EXIT_AFTER_STOP_DELAY 8: const delayMs = exitAfterStopDelay ? parseInt(exitAfterStopDelay, 10) : null 9: const isValidDelay = delayMs && !isNaN(delayMs) && delayMs > 0 10: let timer: NodeJS.Timeout | null = null 11: let lastIdleTime = 0 12: return { 13: start() { 14: if (timer) { 15: clearTimeout(timer) 16: timer = null 17: } 18: if (isValidDelay) { 19: lastIdleTime = Date.now() 20: timer = setTimeout(() => { 21: const idleDuration = Date.now() - lastIdleTime 22: if (isIdle() && idleDuration >= delayMs) { 23: logForDebugging(`Exiting after ${delayMs}ms of idle time`) 24: gracefulShutdownSync() 25: } 26: }, delayMs) 27: } 28: }, 29: stop() { 30: if (timer) { 31: clearTimeout(timer) 32: timer = null 33: } 34: }, 35: } 36: }

File: src/utils/imagePaste.ts

````typescript 1: import { feature } from ‘bun:bundle’ 2: import { randomBytes } from ‘crypto’ 3: import { execa } from ‘execa’ 4: import { basename, extname, isAbsolute, join } from ‘path’ 5: import { 6: IMAGE_MAX_HEIGHT, 7: IMAGE_MAX_WIDTH, 8: IMAGE_TARGET_RAW_SIZE, 9: } from ‘../constants/apiLimits.js’ 10: import { getFeatureValue_CACHED_MAY_BE_STALE } from ‘../services/analytics/growthbook.js’ 11: import { getImageProcessor } from ‘../tools/FileReadTool/imageProcessor.js’ 12: import { logForDebugging } from ‘./debug.js’ 13: import { execFileNoThrowWithCwd } from ‘./execFileNoThrow.js’ 14: import { getFsImplementation } from ‘./fsOperations.js’ 15: import { 16: detectImageFormatFromBase64, 17: type ImageDimensions, 18: maybeResizeAndDownsampleImageBuffer, 19: } from ‘./imageResizer.js’ 20: import { logError } from ‘./log.js’ 21: type SupportedPlatform = ‘darwin’ | ‘linux’ | ‘win32’ 22: export const PASTE_THRESHOLD = 800 23: function getClipboardCommands() { 24: const platform = process.platform as SupportedPlatform 25: const baseTmpDir = 26: process.env.CLAUDE_CODE_TMPDIR || 27: (platform === ‘win32’ ? process.env.TEMP || ‘C:\Temp’ : ‘/tmp’) 28: const screenshotFilename = ‘claude_cli_latest_screenshot.png’ 29: const tempPaths: Record<SupportedPlatform, string> = { 30: darwin: join(baseTmpDir, screenshotFilename), 31: linux: join(baseTmpDir, screenshotFilename), 32: win32: join(baseTmpDir, screenshotFilename), 33: } 34: const screenshotPath = tempPaths[platform] || tempPaths.linux 35: const commands: Record< 36: SupportedPlatform, 37: { 38: checkImage: string 39: saveImage: string 40: getPath: string 41: deleteFile: string 42: }