流出したclaude codeのソースコード7
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: }